diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 64f6844757..4a709d2b96 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -242,6 +242,14 @@ class BackupEdit extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + $this->status = $database->status; + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index e4ed2a366c..8c83e33556 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -17,6 +17,13 @@ class BackupNow extends Component try { $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database->refresh(); + if ($database->id !== 0 && ! str($database->status)->startsWith('running')) { + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index b4236b215a..96d2ac7aaf 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -85,11 +85,10 @@ class CreateScheduledBackup extends Component $databaseBackup = ScheduledDatabaseBackup::create($payload); if ($database->getMorphClass() === ServiceDatabase::class) { $service = $database->service; - $this->redirectRoute('project.service.database.backup.show', [ + $this->redirectRoute('project.service.volume-backups.index', [ 'project_uuid' => $service->project()->uuid, 'environment_uuid' => $service->environment->uuid, 'service_uuid' => $service->uuid, - 'stack_service_uuid' => $database->uuid, 'backup_uuid' => $databaseBackup->uuid, ], navigate: true); } else { diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php index 87f24fb1da..6d7fb97f68 100644 --- a/app/Livewire/Project/Service/BackupExecutions.php +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -9,16 +9,21 @@ use App\Models\ScheduledVolumeBackupExecution; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Query\Builder; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; +use Livewire\WithPagination; class BackupExecutions extends Component { use AuthorizesRequests; + use WithPagination; public Service $service; + public int $perPage = 10; + public bool $executionModalOpen = false; public ?array $selectedExecution = null; @@ -40,10 +45,18 @@ class BackupExecutions extends Component $this->authorize('view', $this->service); } + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage('executionsPage'); + } + public function openExecution(string $executionUuid): void { - $this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid); - abort_unless($this->selectedExecution, 404); + $this->authorize('view', $this->service); + $execution = $this->executionQuery($executionUuid)->first(); + abort_unless($execution, 404); + $this->selectedExecution = $this->formatExecutions(collect([$execution]))->first(); $this->executionModalOpen = true; } @@ -55,70 +68,89 @@ class BackupExecutions extends Component public function render(): View { + $this->authorize('view', $this->service); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + if ($executions->currentPage() > $executions->lastPage()) { + $this->setPage($executions->lastPage(), 'executionsPage'); + $executions = $this->executionQuery()->paginate($this->perPage, pageName: 'executionsPage'); + } + $executions->setCollection($this->formatExecutions($executions->getCollection())); + return view('livewire.project.service.backup-executions', [ - 'executions' => $this->executions(), + 'executions' => $executions, ]); } - private function executions(): Collection + private function executionQuery(?string $uuid = null): Builder { $databaseScheduleIds = ScheduledDatabaseBackup::query() ->where('database_type', (new ServiceDatabase)->getMorphClass()) ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) - ->pluck('id'); + ->select('id'); + $volumeScheduleIds = ScheduledVolumeBackup::query() + ->forService($this->service) + ->select('id'); $databaseExecutions = ScheduledDatabaseBackupExecution::query() - ->with('scheduledDatabaseBackup.database') + ->select('id', 'uuid', 'created_at') + ->selectRaw("'database' as type") ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) - ->latest() - ->limit(100) - ->get() - ->map(fn (ScheduledDatabaseBackupExecution $execution): array => [ - 'id' => 'database:'.$execution->id, + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->select('id', 'uuid', 'created_at') + ->selectRaw("'storage' as type") + ->whereIn('scheduled_volume_backup_id', $volumeScheduleIds) + ->when($uuid !== null, fn ($query) => $query->where('uuid', $uuid)); + + return $databaseExecutions->toBase() + ->unionAll($volumeExecutions->toBase()) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->orderBy('type'); + } + + private function formatExecutions(Collection $rows): Collection + { + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with(['scheduledDatabaseBackup.database', 'scheduledDatabaseBackup.s3']) + ->whereIn('id', $rows->where('type', 'database')->pluck('id')) + ->get()->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->with(['scheduledVolumeBackup.backupable.resource', 's3']) + ->whereIn('id', $rows->where('type', 'storage')->pluck('id')) + ->get()->keyBy('id'); + + return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions): array { + $isDatabase = $row->type === 'database'; + $execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id); + $schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup; + $storage = $isDatabase ? ($schedule->save_s3 ? $schedule->s3 : null) : $execution->s3; + if ($storage?->team_id !== currentTeam()->id) { + $storage = null; + } + $storageLabel = $storage ? $storage->name.' (bucket: '.$storage->bucket.')' : 'Unavailable'; + if ($isDatabase && ! $schedule->save_s3) { + $storageLabel = 'Not configured'; + } elseif (! $isDatabase && ! $execution->s3_storage_id && ! $execution->s3_uploaded && ! $execution->s3_storage_deleted) { + $storageLabel = 'No destination recorded'; + } + + return [ + 'id' => $row->type.':'.$execution->id, 'uuid' => $execution->uuid, - 'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name, - 'type' => 'Database', - 'schedule' => $execution->scheduledDatabaseBackup->frequency, + 'target' => $isDatabase ? ($schedule->database->human_name ?: $schedule->database->name) : $schedule->targetName(), + 'type' => $isDatabase ? 'Database' : $schedule->targetType(), + 'schedule' => $schedule->frequency, + 's3_tooltip' => ($isDatabase ? 'Current schedule S3 storage: ' : 'S3 storage: ').$storageLabel, 'status' => $execution->status, 'started_at' => $execution->created_at, 'size' => $execution->size, 'message' => $execution->message, 'filename' => $execution->filename, 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.backup', $execution->id) + ? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id) : null, - ]); - - $volumeSchedules = ScheduledVolumeBackup::query() - ->with('backupable.resource') - ->forService($this->service) - ->get() - ->keyBy('id'); - $volumeExecutions = ScheduledVolumeBackupExecution::query() - ->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys()) - ->latest() - ->limit(100) - ->get() - ->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array { - $schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id); - - return [ - 'id' => 'storage:'.$execution->id, - 'uuid' => $execution->uuid, - 'target' => $schedule->targetName(), - 'type' => $schedule->targetType(), - 'schedule' => $schedule->frequency, - 'status' => $execution->status, - 'started_at' => $execution->created_at, - 'size' => $execution->size, - 'message' => $execution->message, - 'filename' => $execution->filename, - 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted - ? route('download.volume-backup', $execution->id) - : null, - ]; - }); - - return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values(); + ]; + }); } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index e856d1373a..600b8b9047 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -11,6 +11,7 @@ use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Url; use Livewire\Component; class Index extends Component @@ -23,6 +24,9 @@ class Index extends Component public string $search = ''; + #[Url(as: 'backup_uuid', except: '')] + public string $backupUuid = ''; + public bool $scheduleModalOpen = false; public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; @@ -38,6 +42,7 @@ class Index extends Component return [ 'refreshVolumeBackups' => '$refresh', 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},ServiceChecked" => '$refresh', "echo-private:team.{$teamId},BackupCreated" => '$refresh', ]; } @@ -49,10 +54,14 @@ class Index extends Component $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + if ($this->backupUuid !== '') { + $this->openSchedule($this->backupUuid); + } } public function openSchedule(string $backupUuid): void { + $this->authorize('update', $this->service); $this->loadSelectedSchedule($backupUuid); $this->s3s = currentTeam()->s3s; $this->scheduleModalOpen = true; @@ -60,6 +69,7 @@ class Index extends Component public function closeScheduleModal(): void { + $this->backupUuid = ''; $this->scheduleModalOpen = false; $this->selectedDatabaseBackup = null; $this->selectedVolumeBackup = null; @@ -72,6 +82,12 @@ class Index extends Component $this->loadSelectedSchedule($backupUuid); abort_unless($this->selectedDatabaseBackup, 404); $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + if (! str($this->selectedDatabaseBackup->database->status)->startsWith('running')) { + $this->selectedDatabaseBackup = null; + $this->dispatch('error', 'The database must be running to start a backup.'); + + return; + } DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); } else { abort_unless($type === 'storage', 404); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index 9ea87161d8..5d0d2538bd 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -5,11 +5,29 @@ namespace App\Livewire\Server; use App\Models\Server; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Pagination\LengthAwarePaginator; use Livewire\Component; +use Livewire\WithPagination; class Resources extends Component { use AuthorizesRequests; + use WithPagination; + + public int $perPage = 10; + + public string $search = ''; + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedPerPage(): void + { + $this->perPage = max(1, min(100, $this->perPage)); + $this->resetPage(); + } public ?Server $server = null; @@ -93,6 +111,10 @@ class Resources extends Component public function loadManagedContainers() { try { + if ($this->activeTab !== 'managed') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'managed'; $this->server->refresh(); } catch (\Throwable $e) { @@ -102,6 +124,10 @@ class Resources extends Component public function loadUnmanagedContainers() { + if ($this->activeTab !== 'unmanaged') { + $this->search = ''; + $this->resetPage(); + } $this->activeTab = 'unmanaged'; try { $this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray(); @@ -125,6 +151,29 @@ class Resources extends Component public function render() { - return view('livewire.server.resources'); + $resources = $this->activeTab === 'managed' + ? $this->server->definedResources()->sortBy('name', SORT_NATURAL) + : collect($this->unmanagedContainers)->sortBy('Names', SORT_NATURAL); + $search = trim($this->search); + if ($search !== '') { + $nameKey = $this->activeTab === 'managed' ? 'name' : 'Names'; + $resources = $resources->filter(fn ($resource) => str((string) data_get($resource, $nameKey)) + ->contains($search, ignoreCase: true)); + } + $this->perPage = max(1, min(100, $this->perPage)); + $lastPage = max(1, (int) ceil($resources->count() / $this->perPage)); + $page = max(1, min((int) $this->getPage(), $lastPage)); + if ($page !== $this->getPage()) { + $this->setPage($page); + } + + return view('livewire.server.resources', [ + 'resources' => new LengthAwarePaginator( + $resources->forPage($page, $this->perPage)->values(), + $resources->count(), + $this->perPage, + $page, + ), + ]); } } diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php index 1190cd59a1..49739ac6dd 100644 --- a/app/Livewire/Server/Sentinel/Logs.php +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server\Sentinel; +use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; @@ -29,6 +30,35 @@ class Logs extends Component $this->authorize('viewSentinel', $this->server); } + public function enableSentinel(): void + { + $this->authorize('manageSentinel', $this->server); + + try { + $this->server->refresh(); + if ($this->server->isBuildServer()) { + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + + return; + } + if ($this->server->isSwarm()) { + $this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.'); + + return; + } + if ($this->server->isSentinelEnabled()) { + return; + } + + StartSentinel::run($this->server, true); + $this->server->refresh(); + $this->dispatch('refreshServerShow'); + $this->dispatch('success', 'Sentinel has been enabled.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function render(): View { return view('livewire.server.sentinel.logs'); diff --git a/resources/css/app.css b/resources/css/app.css index 06f0b251af..65bce6327d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2521,7 +2521,7 @@ input[type="search"]::-webkit-search-results-decoration { } .service-backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 7.5rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 12.5rem; width: 100%; } diff --git a/resources/views/components/shared-variables/editor.blade.php b/resources/views/components/shared-variables/editor.blade.php index 810b7f69cf..c2c85a3e4e 100644 --- a/resources/views/components/shared-variables/editor.blade.php +++ b/resources/views/components/shared-variables/editor.blade.php @@ -5,6 +5,7 @@ 'title', 'view', 'variablesLabel', + 'readOnlyKeys' => [], ]) @php @@ -105,15 +106,32 @@