Files
coolify/app/Livewire/Project/Service/BackupExecutions.php
T
Andras Bacsai fa1895c99a feat(dashboard): add canvas view with active deployment overlay
Add a /dashboard/canvas page that renders every team server with its
resources and their running or queued deployments, backed by a new
ServerCanvasController::dashboard endpoint and a per-server usage
endpoint for zone headers. The dashboard remembers the list/canvas
choice and each section (projects, servers) its own table/grid view.

- Show Deploying/Queued pills on canvas cards and a foldable recent
  deployments panel; add shared CanvasControls component
- Allow backup downloads from the server host via the S3 download
  storage (PrepareFileDownloadJob accepts a null container)
- Extract backup toggle/run actions into ManagesDatabaseBackup and
  surface them in backup execution headers; merge S3 and retention
  sections into the general backup settings
- Move deployments indicator to the top bar and add a main nav
  resource menu with submenus; simplify breadcrumbs and settings
  layouts
- Add shared variables overview canvas and scheduled task modals
- Add feature tests for dashboard canvas, view modes, navbar
  submenus, backup S3 downloads and scheduled task modals
2026-10-14 23:55:28 +02:00

192 lines
8.0 KiB
PHP

<?php
namespace App\Livewire\Project\Service;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Traits\ListensToTeamChannel;
use App\Traits\PreparesBackupDownloads;
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 ListensToTeamChannel;
use PreparesBackupDownloads;
use WithPagination;
public Service $service;
public int $perPage = 10;
public bool $executionModalOpen = false;
public ?array $selectedExecution = null;
public function getListeners(): array
{
return [
'modalClosed' => 'closeExecutionModal',
...$this->teamChannelListeners([
'BackupCreated' => '$refresh',
]),
];
}
public function mount(Service $service): void
{
abort_unless($service->environment?->project?->team_id === currentTeam()->id, 404);
$this->service = $service;
$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->authorize('view', $this->service);
$execution = $this->executionQuery($executionUuid)->first();
abort_unless($execution, 404);
$this->selectedExecution = $this->formatExecutions(collect([$execution]))->first();
$this->executionModalOpen = true;
}
public function downloadExecution(string $executionUuid): void
{
$this->authorize('update', $this->service);
$row = $this->executionQuery($executionUuid)->first();
abort_unless($row, 404);
try {
if ($row->type === 'database') {
$execution = ScheduledDatabaseBackupExecution::query()->with('scheduledDatabaseBackup')->findOrFail($row->id);
$server = $execution->scheduledDatabaseBackup->server();
$streamUrl = route('download.backup', $execution->id);
} else {
$execution = ScheduledVolumeBackupExecution::query()->with('scheduledVolumeBackup')->findOrFail($row->id);
$server = $execution->scheduledVolumeBackup->server();
$streamUrl = route('download.volume-backup', $execution->id);
}
$this->prepareBackupDownload($execution->local_storage_deleted ? null : $server, $execution->filename, $streamUrl);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function closeExecutionModal(): void
{
$this->executionModalOpen = false;
$this->selectedExecution = null;
}
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' => $executions,
's3Downloads' => $this->s3BackupDownloadsEnabled(),
]);
}
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))
->select('id');
$volumeScheduleIds = ScheduledVolumeBackup::query()
->forService($this->service)
->select('id');
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
->select('id', 'uuid', 'created_at')
->selectRaw("'database' as type")
->whereIn('scheduled_database_backup_id', $databaseScheduleIds)
->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', 'scheduledDatabaseBackup.s3Storages', 's3Replicas'])
->whereIn('id', $rows->where('type', 'database')->pluck('id'))
->get()->keyBy('id');
$volumeExecutions = ScheduledVolumeBackupExecution::query()
->with(['scheduledVolumeBackup.backupable.resource', 's3', 's3Replicas'])
->whereIn('id', $rows->where('type', 'storage')->pluck('id'))
->get()->keyBy('id');
$serviceTeamId = $this->service->team()?->id;
return $rows->map(function (object $row) use ($databaseExecutions, $volumeExecutions, $serviceTeamId): array {
$isDatabase = $row->type === 'database';
$execution = $isDatabase ? $databaseExecutions->get($row->id) : $volumeExecutions->get($row->id);
$schedule = $isDatabase ? $execution->scheduledDatabaseBackup : $execution->scheduledVolumeBackup;
$storages = match (true) {
! $isDatabase => collect([$execution->s3]),
! $schedule->save_s3 => collect(),
$schedule->s3Storages->isNotEmpty() => $schedule->s3Storages,
default => collect([$schedule->s3]),
};
$storageLabel = $storages
->filter(fn ($storage) => $storage?->team_id === $serviceTeamId)
->map(fn ($storage) => $storage->name.' (bucket: '.$storage->bucket.')')
->implode(', ') ?: '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' => $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->isDownloadable()
? route($isDatabase ? 'download.backup' : 'download.volume-backup', $execution->id)
: null,
];
});
}
}