feat(backups): unify service backup management (#11574)

This commit is contained in:
Andras Bacsai
2026-08-31 22:51:30 +02:00
committed by GitHub
parent ff8b019296
commit e4146a6314
26 changed files with 1066 additions and 140 deletions
+8 -3
View File
@@ -322,6 +322,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) {
if ($database === '*') {
@@ -343,6 +344,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) {
$this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp';
@@ -357,6 +359,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) {
$this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp';
@@ -371,6 +374,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_mariadb($database);
} elseif ($this->database instanceof StandaloneClickhouse) {
$this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip";
@@ -382,6 +386,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
BackupCreated::dispatch($this->team->id);
$this->backup_standalone_clickhouse($database);
} else {
throw new \Exception('Unsupported database type');
@@ -480,14 +485,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} catch (Throwable $e) {
throw $e;
} finally {
if ($this->team) {
BackupCreated::dispatch($this->team->id);
}
if ($this->backup_log) {
$this->backup_log->update([
'finished_at' => Carbon::now()->toImmutable(),
]);
}
if ($this->team) {
BackupCreated::dispatch($this->team->id);
}
}
}
@@ -6,7 +6,6 @@ use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class BackupExecutions extends Component
@@ -37,12 +36,12 @@ class BackupExecutions extends Component
public $delete_backup_sftp = false;
public function getListeners()
public function getListeners(): array
{
$userId = Auth::id();
$teamId = currentTeam()->id;
return [
"echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions',
"echo-private:team.{$teamId},BackupCreated" => 'refreshBackupExecutions',
];
}
@@ -0,0 +1,124 @@
<?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 Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class BackupExecutions extends Component
{
use AuthorizesRequests;
public Service $service;
public bool $executionModalOpen = false;
public ?array $selectedExecution = null;
public function getListeners(): array
{
$teamId = currentTeam()->id;
return [
'modalClosed' => 'closeExecutionModal',
"echo-private:team.{$teamId},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 openExecution(string $executionUuid): void
{
$this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid);
abort_unless($this->selectedExecution, 404);
$this->executionModalOpen = true;
}
public function closeExecutionModal(): void
{
$this->executionModalOpen = false;
$this->selectedExecution = null;
}
public function render(): View
{
return view('livewire.project.service.backup-executions', [
'executions' => $this->executions(),
]);
}
private function executions(): Collection
{
$databaseScheduleIds = ScheduledDatabaseBackup::query()
->where('database_type', (new ServiceDatabase)->getMorphClass())
->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id))
->pluck('id');
$databaseExecutions = ScheduledDatabaseBackupExecution::query()
->with('scheduledDatabaseBackup.database')
->whereIn('scheduled_database_backup_id', $databaseScheduleIds)
->latest()
->limit(100)
->get()
->map(fn (ScheduledDatabaseBackupExecution $execution): array => [
'id' => 'database:'.$execution->id,
'uuid' => $execution->uuid,
'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name,
'type' => 'Database',
'schedule' => $execution->scheduledDatabaseBackup->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.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();
}
}
@@ -22,8 +22,6 @@ class DatabaseBackups extends Component
public array $query;
public bool $isImportSupported = false;
public ?ScheduledDatabaseBackup $backup = null;
public string $section = 'index';
@@ -32,7 +30,7 @@ class DatabaseBackups extends Component
protected $listeners = ['refreshScheduledBackups' => '$refresh'];
public function mount()
public function mount(): mixed
{
try {
$this->parameters = array_filter(
@@ -67,10 +65,13 @@ class DatabaseBackups extends Component
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));
if (! request()->route('backup_uuid')) {
return redirect()->route('project.service.volume-backups.index', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
if (request()->route('backup_uuid')) {
$this->backup = $this->serviceDatabase->scheduledBackups()
@@ -85,6 +86,14 @@ class DatabaseBackups extends Component
'project.service.database.backup.danger' => 'danger',
default => 'general',
};
$routeParameters = [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
];
return redirect()->route('project.service.volume-backups.index', $routeParameters);
}
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -0,0 +1,80 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
class ImportBackup extends Component
{
use AuthorizesRequests;
public Service $service;
public Collection $databases;
public ?ServiceDatabase $selectedDatabase = null;
public string $selectedDatabaseUuid = '';
public array $parameters;
public function mount(): mixed
{
$this->parameters = get_route_parameters();
$project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('update', $this->service);
$this->databases = $this->service->databases
->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database))
->values();
$databaseUuid = request()->route('stack_service_uuid');
if ($databaseUuid) {
$selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid);
abort_unless($selectedDatabase instanceof ServiceDatabase, 404);
$this->authorize('update', $selectedDatabase);
$this->selectedDatabase = $selectedDatabase;
$this->selectedDatabaseUuid = $selectedDatabase->uuid;
if (request()->routeIs('project.service.database.import')) {
return redirect()->route('project.service.import-backup.database', $this->parameters);
}
} elseif ($this->databases->count() === 1) {
return redirect()->route('project.service.import-backup.database', [
...$this->parameters,
'stack_service_uuid' => $this->databases->first()->uuid,
]);
}
return null;
}
public function updatedSelectedDatabaseUuid(): mixed
{
$database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid);
abort_unless($database instanceof ServiceDatabase, 404);
$this->authorize('update', $database);
return redirect()->route('project.service.import-backup.database', [
...$this->parameters,
'stack_service_uuid' => $database->uuid,
]);
}
public function render(): View
{
return view('livewire.project.service.import-backup');
}
private function supportsImport(ServiceDatabase $database): bool
{
return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']);
}
}
-6
View File
@@ -59,8 +59,6 @@ class Index extends Component
public bool $isLogDrainEnabled = false;
public bool $isImportSupported = false;
// Application-specific properties
public $docker_cleanup = true;
@@ -153,10 +151,6 @@ class Index extends Component
$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
@@ -2,11 +2,14 @@
namespace App\Livewire\Project\Service\VolumeBackup;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\VolumeBackupJob;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledVolumeBackup;
use App\Models\Service;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -20,14 +23,70 @@ class Index extends Component
public string $search = '';
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
public bool $scheduleModalOpen = false;
public function mount(): void
public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null;
public ?ScheduledVolumeBackup $selectedVolumeBackup = null;
public ?Collection $s3s = null;
public function getListeners(): array
{
$this->service = $this->findService();
$teamId = currentTeam()->id;
return [
'refreshVolumeBackups' => '$refresh',
'modalClosed' => 'closeScheduleModal',
"echo-private:team.{$teamId},BackupCreated" => '$refresh',
];
}
public function mount(?Service $service = null): void
{
$this->service = $service ?? $this->findService();
$this->authorize('view', $this->service);
$this->parameters = get_route_parameters();
$this->search = request()->string('search')->toString();
}
public function openSchedule(string $backupUuid): void
{
$this->loadSelectedSchedule($backupUuid);
$this->s3s = currentTeam()->s3s;
$this->scheduleModalOpen = true;
}
public function closeScheduleModal(): void
{
$this->scheduleModalOpen = false;
$this->selectedDatabaseBackup = null;
$this->selectedVolumeBackup = null;
}
public function backupNow(string $type, string $backupUuid): void
{
try {
if ($type === 'database') {
$this->loadSelectedSchedule($backupUuid);
abort_unless($this->selectedDatabaseBackup, 404);
$this->authorize('manageBackups', $this->selectedDatabaseBackup->database);
DatabaseBackupJob::dispatch($this->selectedDatabaseBackup);
} else {
abort_unless($type === 'storage', 404);
$this->loadSelectedSchedule($backupUuid);
abort_unless($this->selectedVolumeBackup, 404);
$this->authorize('update', $this->selectedVolumeBackup->targetResource());
VolumeBackupJob::dispatch($this->selectedVolumeBackup);
}
$this->selectedDatabaseBackup = null;
$this->selectedVolumeBackup = null;
$this->dispatch('success', 'Backup queued.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render(): View
@@ -68,4 +127,24 @@ class Index extends Component
->where('uuid', request()->route('service_uuid'))
->firstOrFail();
}
private function loadSelectedSchedule(string $backupUuid): void
{
$this->selectedDatabaseBackup = ScheduledDatabaseBackup::query()
->with('database')
->whereUuid($backupUuid)
->where('database_type', (new ServiceDatabase)->getMorphClass())
->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id))
->first();
if ($this->selectedDatabaseBackup) {
return;
}
$this->selectedVolumeBackup = ScheduledVolumeBackup::query()
->with('backupable.resource')
->whereUuid($backupUuid)
->forService($this->service)
->firstOrFail();
}
}
@@ -20,7 +20,7 @@ class Show extends Component
public string $section = 'general';
public function mount(): void
public function mount(): mixed
{
$project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail();
$environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
@@ -43,6 +43,10 @@ class Show extends Component
'project.service.volume-backups.danger' => 'danger',
default => 'general',
};
$routeParameters = collect($this->parameters)->except('backup_uuid')->all();
return redirect()->route('project.service.volume-backups.index', $routeParameters);
}
public function render(): View
+19 -2
View File
@@ -2521,8 +2521,25 @@ 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);
min-width: 45rem;
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 7.5rem;
width: 100%;
}
.data-table-row.service-backup-table-grid {
background: var(--coollabs-base);
border-bottom: 1px solid var(--coollabs-fill);
}
.data-table-row.service-backup-table-grid:last-child {
border-bottom: 0;
}
.data-table-row.service-backup-table-grid:hover {
background: color-mix(in srgb, var(--coollabs-base) 98%, black);
}
.dark .data-table-row.service-backup-table-grid:hover {
background: color-mix(in srgb, var(--coollabs-base) 98%, white);
}
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */
@@ -15,7 +15,7 @@
'danger' => 'project.application.backup.danger',
],
'service' => [
'back' => 'project.service.database.backups',
'back' => 'project.service.volume-backups.index',
'general' => 'project.service.database.backup.show',
's3' => 'project.service.database.backup.s3',
'retention' => 'project.service.database.backup.retention',
@@ -48,9 +48,11 @@
['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'],
];
$backLabel = $context === 'database' ? 'Back to database' : 'Back to backups';
$backParameters = $context === 'database'
? collect($parameters)->except('backup_uuid')->all()
: $parameters;
$backParameters = match ($context) {
'database' => collect($parameters)->except('backup_uuid')->all(),
'service' => collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all(),
default => $parameters,
};
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
@@ -0,0 +1,62 @@
@props([
'context',
'parameters',
'section',
])
@php
$routes = match ($context) {
'service-schedule' => [
'general' => true,
's3' => true,
'retention' => true,
'danger' => true,
],
'service' => [
'general' => 'project.service.database.backup.show',
's3' => 'project.service.database.backup.s3',
'retention' => 'project.service.database.backup.retention',
'executions' => 'project.service.database.backup.executions',
'danger' => 'project.service.database.backup.danger',
],
'service-volume' => [
'general' => 'project.service.volume-backups.show',
's3' => 'project.service.volume-backups.s3',
'retention' => 'project.service.volume-backups.retention',
'executions' => 'project.service.volume-backups.executions',
'danger' => 'project.service.volume-backups.danger',
],
};
$items = collect([
['key' => 'general', 'label' => 'General'],
['key' => 's3', 'label' => 'S3 storage'],
['key' => 'retention', 'label' => 'Retention'],
['key' => 'executions', 'label' => 'Executions'],
['key' => 'danger', 'label' => 'Danger Zone'],
])->filter(fn (array $item): bool => isset($routes[$item['key']]));
@endphp
<nav aria-label="Backup sections"
class="flex min-w-0 flex-wrap gap-1 border-b border-neutral-200 pb-2 dark:border-white/[0.08]">
@foreach ($items as $item)
@if ($context === 'service-schedule')
<button type="button" @click="activeSection = '{{ $item['key'] }}'"
:class="activeSection === '{{ $item['key'] }}'
? 'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25'
: 'text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg'"
class="inline-flex h-8 shrink-0 cursor-pointer items-center rounded-md px-3 text-[13px] font-medium transition-colors">
{{ $item['label'] }}
</button>
@else
<a @class([
'inline-flex h-8 shrink-0 items-center rounded-md px-3 text-[13px] font-medium transition-colors',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $section === $item['key'],
'text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg' => $section !== $item['key'],
])
{{ wireNavigate() }} href="{{ route($routes[$item['key']], $parameters) }}">
{{ $item['label'] }}
</a>
@endif
@endforeach
</nav>
@@ -1,11 +1,9 @@
@props([
'parameters',
'serviceDatabase',
'isImportSupported' => false,
])
@php
$serviceParameters = \Illuminate\Support\Arr::except($parameters, ['stack_service_uuid']);
$items = [
[
'label' => 'General',
@@ -19,22 +17,6 @@
'icon' => 'grid',
'active' => request()->routeIs('project.service.index.advanced'),
],
[
'label' => 'Backups',
'route' => 'project.service.volume-backups.index',
'parameters' => $serviceParameters,
'icon' => 'storages',
'active' => request()->routeIs('project.service.database.backup*'),
'visible' => $serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated,
],
[
'label' => 'Import Backup',
'route' => 'project.service.database.import',
'icon' => 'upload',
'active' => request()->routeIs('project.service.database.import'),
'visible' => $isImportSupported,
'navigate' => false,
],
];
$items = array_values(array_filter($items, fn (array $item): bool => $item['visible'] ?? true));
@@ -13,6 +13,7 @@
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false],
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
@@ -27,13 +28,15 @@
|| ($item['route'] === 'project.service.scheduled-tasks.show'
&& str($currentRoute)->startsWith('project.service.scheduled-tasks'))
|| ($item['route'] === 'project.service.volume-backups.index'
&& str($currentRoute)->startsWith('project.service.volume-backups')),
&& str($currentRoute)->startsWith('project.service.volume-backups'))
|| ($item['route'] === 'project.service.import-backup'
&& str($currentRoute)->startsWith('project.service.import-backup')),
]);
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'],
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
];
@@ -0,0 +1,64 @@
<div>
@if ($selectedExecution)
<x-modal-input title="Backup execution" wireOpen="executionModalOpen" :wireIgnore="false" isLarge>
<x-slot:content><span></span></x-slot:content>
<div class="flex flex-col gap-5">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Target</p><p class="mt-1 text-sm font-medium">{{ $selectedExecution['target'] }}</p></div>
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Status</p><p class="mt-1 text-sm font-medium">{{ str($selectedExecution['status'])->headline() }}</p></div>
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Started</p><p class="mt-1 text-sm font-medium">{{ $selectedExecution['started_at']->diffForHumans() }}</p></div>
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Size</p><p class="mt-1 text-sm font-medium">{{ $selectedExecution['size'] ? formatBytes($selectedExecution['size']) : '-' }}</p></div>
</div>
@if ($selectedExecution['filename'])
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Backup path</p><code class="mt-1 block overflow-x-auto rounded-md bg-neutral-100 p-3 text-xs dark:bg-black/20">{{ $selectedExecution['filename'] }}</code></div>
@endif
@if ($selectedExecution['message'])
<div><p class="text-xs text-neutral-500 dark:text-fg-dim">Output</p><pre class="mt-1 max-h-80 overflow-auto rounded-md bg-neutral-100 p-3 font-mono text-xs whitespace-pre-wrap dark:bg-black/20">{{ $selectedExecution['message'] }}</pre></div>
@endif
</div>
</x-modal-input>
@endif
<x-application.settings-section title="Executions"
helper="Review backup runs across every database and storage target in this service." flush>
@if ($executions->isEmpty())
<x-empty size="sm" title="No backup executions"
description="Execution history appears here after a backup schedule runs." icon-name="browser-terminal" />
@else
<div class="data-table w-full overflow-x-auto">
<div class="data-table-header grid min-w-[820px] grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px]">
<span>Target</span><span>Type</span><span>Schedule</span><span>Status</span><span>Started</span><span>Size</span><span class="text-right">Actions</span>
</div>
@foreach ($executions as $execution)
@php
$statusType = match ($execution['status']) {
'success' => 'success',
'failed' => 'error',
'running' => 'warning',
default => 'neutral',
};
@endphp
<div wire:key="service-backup-execution-{{ $execution['id'] }}"
wire:click="openExecution('{{ $execution['uuid'] }}')"
wire:keydown.enter="openExecution('{{ $execution['uuid'] }}')" role="button" tabindex="0"
class="data-table-row grid min-w-[820px] cursor-pointer grid-cols-[minmax(150px,1.4fr)_100px_100px_110px_110px_90px_48px] text-left text-[13px] text-neutral-700 dark:text-fg-dim">
<span class="truncate font-medium text-neutral-950 dark:text-fg" title="{{ $execution['target'] }}">{{ $execution['target'] }}</span>
<span>{{ $execution['type'] }}</span><span>{{ $execution['schedule'] }}</span>
<span><x-status-badge :status="str($execution['status'])->headline()" :type="$statusType" /></span>
<span>{{ $execution['started_at']->diffForHumans() }}</span>
<span>{{ $execution['size'] ? formatBytes($execution['size']) : '-' }}</span>
<span class="flex justify-end">
@if ($execution['download_url'])
<a href="{{ $execution['download_url'] }}" target="_blank" rel="noopener"
@click.stop class="icon-button shrink-0" title="Download backup"
aria-label="Download backup">
<x-reicon name="upload" class="size-3.5 rotate-180" />
</a>
@endif
</span>
</div>
@endforeach
</div>
@endif
</x-application.settings-section>
</div>
@@ -18,6 +18,7 @@
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false],
['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
@@ -35,7 +36,7 @@
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'],
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
];
@@ -8,24 +8,41 @@
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
@if ($backup)
<x-backup-sidebar context="service" :parameters="$backupParameters" :section="$section" />
@else
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase"
:isImportSupported="$isImportSupported" />
@endif
<x-service.configuration-sidebar :service="$service"
current-route="project.service.volume-backups.index" />
<div class="min-w-0">
@if ($backup)
@if ($section === 'executions')
<livewire:project.database.backup-executions :backup="$backup"
:database="$serviceDatabase" />
@else
<livewire:project.database.backup-edit :backup="$backup"
:available-s3-storages="$s3s" :status="data_get($serviceDatabase, 'status')"
:section="$section"
wire:key="service-database-backup-{{ $backup->uuid }}-{{ $section }}" />
@endif
<div class="flex min-w-0 flex-col gap-6">
<div class="flex min-w-0 flex-col gap-4">
<div>
<a class="inline-flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg"
{{ wireNavigate() }}
href="{{ route('project.service.volume-backups.index', collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all()) }}">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
Back to backups
</a>
<h1 class="mt-2 text-xl font-semibold text-neutral-950 dark:text-fg">
{{ $serviceDatabase->human_name ?: $serviceDatabase->name }} backup
</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $backup->frequency }} schedule
</p>
</div>
<x-backup-tabs context="service" :parameters="$backupParameters" :section="$section" />
</div>
@if ($section === 'executions')
<livewire:project.database.backup-executions :backup="$backup"
:database="$serviceDatabase" />
@else
<livewire:project.database.backup-edit :backup="$backup"
:available-s3-storages="$s3s" :status="data_get($serviceDatabase, 'status')"
:section="$section"
wire:key="service-database-backup-{{ $backup->uuid }}-{{ $section }}" />
@endif
</div>
@else
<section class="application-settings-section">
<div class="application-settings-section-header">
@@ -0,0 +1,39 @@
<div>
<x-slot:title>
{{ data_get_str($service, 'name')->limit(10) }} > Import Backup | Coolify
</x-slot>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
wire:key="service-heading-import-backup" />
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-service.configuration-sidebar :service="$service" current-route="project.service.import-backup" />
<div class="application-settings-form min-w-0 flex flex-col gap-6">
@if ($databases->isEmpty())
<x-application.settings-section title="Import Backup"
helper="Restore a backup into a database in this service.">
<x-empty title="No compatible databases"
description="This service does not contain a database that supports backup imports."
icon-name="database" size="sm" />
</x-application.settings-section>
@else
<x-application.settings-section title="Import Backup"
helper="Choose the database that should receive the backup.">
<x-forms.listbox id="selectedDatabaseUuid" label="Database" live required canGate="update"
:canResource="$service"
:options="$databases->map(fn ($database) => [
'value' => $database->uuid,
'label' => $database->human_name ?: $database->name,
])->all()" />
</x-application.settings-section>
@if ($selectedDatabase)
<livewire:project.database.import :key="'service-import-' . $selectedDatabase->uuid" />
@endif
@endif
</div>
</div>
</section>
</div>
@@ -3,7 +3,7 @@
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
@if ($resourceType === 'database')
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" />
@else
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Compose resource settings"
@@ -56,6 +56,44 @@
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
wire:key="service-heading-volume-backup-index" />
@if ($selectedDatabaseBackup || $selectedVolumeBackup)
@php
$selectedSchedule = $selectedDatabaseBackup ?: $selectedVolumeBackup;
@endphp
<x-modal-input :title="'Edit backup schedule'" wireOpen="scheduleModalOpen" :wireIgnore="false" isLarge
canGate="update" :canResource="$service">
<x-slot:content><span></span></x-slot:content>
<div x-data="{ activeSection: 'general' }" class="flex min-w-0 flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-neutral-950 dark:text-fg">
{{ $selectedDatabaseBackup
? ($selectedDatabaseBackup->database->human_name ?: $selectedDatabaseBackup->database->name)
: $selectedVolumeBackup->targetName() }}
</h2>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">{{ $selectedSchedule->frequency }} schedule</p>
</div>
<x-backup-tabs context="service-schedule" :parameters="$parameters" section="general" />
@foreach (['general', 's3', 'retention', 'danger'] as $modalSection)
<div x-show="activeSection === '{{ $modalSection }}'" x-cloak>
@if ($selectedDatabaseBackup)
<livewire:project.database.backup-edit :backup="$selectedDatabaseBackup"
:available-s3-storages="$s3s" :status="data_get($selectedDatabaseBackup->database, 'status')"
:section="$modalSection"
wire:key="service-database-backup-modal-{{ $selectedDatabaseBackup->uuid }}-{{ $modalSection }}" />
@else
<livewire:project.shared.storages.volume-backups :storage="$selectedVolumeBackup->backupable"
:resource="$service" :section="$modalSection"
wire:key="service-volume-backup-modal-{{ $selectedVolumeBackup->uuid }}-{{ $modalSection }}" />
@endif
</div>
@endforeach
</div>
</x-modal-input>
@endif
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-service.configuration-sidebar :service="$service"
@@ -190,9 +228,11 @@
</div>
<div @class([
'application-settings-section-body w-full',
'application-settings-section-body relative w-full',
'is-flush' => $backups->isNotEmpty() || $databaseBackups->isNotEmpty(),
])>
<x-table.loading target="openSchedule" text="Loading schedule..." />
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
<x-empty size="sm" title="No backups found"
description="No scheduled backups match your search." />
@@ -200,6 +240,7 @@
@if ($backups->isNotEmpty() || $databaseBackups->isNotEmpty())
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="min-w-[59rem]">
<div class="data-table-header backup-table-grid service-backup-table-grid">
<span>Target</span>
<span>Type</span>
@@ -207,6 +248,7 @@
<span>Status</span>
<span>S3</span>
<span>Last run</span>
<span class="text-right">Actions</span>
</div>
@foreach ($databaseBackups as $databaseBackup)
@@ -227,16 +269,12 @@
};
$databaseBackupId = 'database:'.$databaseBackup->id;
@endphp
<a wire:key="database-backup-{{ $databaseBackup->uuid }}"
<div wire:key="database-backup-{{ $databaseBackup->uuid }}"
x-show="isVisible(@js($databaseBackupId))"
x-bind:style="{ order: backupOrder(@js($databaseBackupId)) }"
href="{{ route('project.service.database.backup.show', [
...$parameters,
'stack_service_uuid' => $databaseBackup->database->uuid,
'backup_uuid' => $databaseBackup->uuid,
]) }}"
{{ wireNavigate() }}
class="data-table-row backup-table-grid text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
wire:click="openSchedule('{{ $databaseBackup->uuid }}')"
wire:keydown.enter="openSchedule('{{ $databaseBackup->uuid }}')" role="button" tabindex="0"
class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg">
{{ $databaseBackup->database->human_name ?: $databaseBackup->database->name }}
</span>
@@ -248,7 +286,12 @@
:type="$databaseBackup->save_s3 ? ($databaseBackup->s3 ? 'success' : 'error') : 'neutral'" />
</span>
<span>{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}</span>
</a>
<span class="flex justify-end">
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="backupNow('database', '{{ $databaseBackup->uuid }}')"
wire:target="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now</x-forms.button>
</span>
</div>
@endforeach
@foreach ($backups as $backup)
@@ -268,12 +311,12 @@
default => 'neutral',
};
@endphp
<a wire:key="volume-backup-{{ $backup->uuid }}"
<div wire:key="volume-backup-{{ $backup->uuid }}"
x-show="isVisible(@js('storage:'.$backup->id))"
x-bind:style="{ order: backupOrder(@js('storage:'.$backup->id)) }"
href="{{ route('project.service.volume-backups.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"
{{ wireNavigate() }}
class="data-table-row backup-table-grid text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
wire:click="openSchedule('{{ $backup->uuid }}')"
wire:keydown.enter="openSchedule('{{ $backup->uuid }}')" role="button" tabindex="0"
class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
title="{{ $backup->targetName() }}">
{{ $backup->targetName() }}
@@ -288,8 +331,14 @@
<span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
</span>
</a>
<span class="flex justify-end">
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="backupNow('storage', '{{ $backup->uuid }}')"
wire:target="backupNow('storage', '{{ $backup->uuid }}')">Back up now</x-forms.button>
</span>
</div>
@endforeach
</div>
</div>
@else
<x-empty size="sm" title="No scheduled backups"
@@ -297,6 +346,8 @@
icon-name="storages" />
@endif
</div>
<livewire:project.service.backup-executions :service="$service"
wire:key="service-backup-executions-{{ $service->id }}" />
</div>
</div>
</section>
@@ -8,9 +8,29 @@
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-backup-sidebar context="service-volume" :parameters="$parameters" :section="$section" />
<x-service.configuration-sidebar :service="$service"
current-route="project.service.volume-backups.index" />
<div class="flex min-w-0 flex-col gap-6">
<div class="flex min-w-0 flex-col gap-4">
<div>
<a class="inline-flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-900 dark:text-fg-dim dark:hover:text-fg"
{{ wireNavigate() }}
href="{{ route('project.service.volume-backups.index', collect($parameters)->except('backup_uuid')->all()) }}">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
Back to backups
</a>
<h1 class="mt-2 text-xl font-semibold text-neutral-950 dark:text-fg">
{{ $backup->targetName() }} backup
</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $backup->frequency }} schedule
</p>
</div>
<x-backup-tabs context="service-volume" :parameters="$parameters" :section="$section" />
</div>
<div class="min-w-0">
<livewire:project.shared.storages.volume-backups :storage="$backup->backupable"
:resource="$service" :section="$section"
wire:key="service-volume-backup-{{ $backup->uuid }}-{{ $section }}" />
+4 -1
View File
@@ -37,6 +37,7 @@ use App\Livewire\Project\Resource\Create as ResourceCreate;
use App\Livewire\Project\Resource\Index as ResourceIndex;
use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
use App\Livewire\Project\Service\ImportBackup as ServiceImportBackup;
use App\Livewire\Project\Service\Index as ServiceIndex;
use App\Livewire\Project\Service\VolumeBackup\Index;
use App\Livewire\Project\Service\VolumeBackup\Show;
@@ -322,6 +323,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/logs', Logs::class)->name('project.service.logs');
Route::get('/environment-variables', ServiceConfiguration::class)->name('project.service.environment-variables');
Route::get('/storages', ServiceConfiguration::class)->name('project.service.storages');
Route::get('/import-backup', ServiceImportBackup::class)->name('project.service.import-backup')->middleware('can.update.resource');
Route::get('/import-backup/{stack_service_uuid}', ServiceImportBackup::class)->name('project.service.import-backup.database')->middleware('can.update.resource');
Route::get('/storage-backups', Index::class)->name('project.service.volume-backups.index');
Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show');
Route::get('/storage-backups/{backup_uuid}/s3', Show::class)->name('project.service.volume-backups.s3');
@@ -340,7 +343,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/{stack_service_uuid}/backups/{backup_uuid}/retention', ServiceDatabaseBackups::class)->name('project.service.database.backup.retention');
Route::get('/{stack_service_uuid}/backups/{backup_uuid}/executions', ServiceDatabaseBackups::class)->name('project.service.database.backup.executions');
Route::get('/{stack_service_uuid}/backups/{backup_uuid}/danger', ServiceDatabaseBackups::class)->name('project.service.database.backup.danger');
Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource');
Route::get('/{stack_service_uuid}/import', ServiceImportBackup::class)->name('project.service.database.import')->middleware('can.update.resource');
Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced');
Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index');
Route::get('/tasks/{task_uuid}', ServiceConfiguration::class)->name('project.service.scheduled-tasks');
+3 -3
View File
@@ -28,11 +28,11 @@ it('uses the upload reicon for import backup in database configuration nav', fun
->toContain("'icon' => 'upload'");
});
it('uses the upload reicon for import backup in service database sidebar', function () {
$contents = file_get_contents(resource_path('views/components/service-database/sidebar.blade.php'));
it('uses the upload reicon for import backup in service navigation', function () {
$contents = file_get_contents(resource_path('views/components/service/configuration-sidebar.blade.php'));
expect($contents)
->toContain("'label' => 'Import Backup'")
->toContain("'icon' => 'upload'")
->not->toMatch("/'label' => 'Import Backup',\s*'route' => 'project\.service\.database\.import',\s*'icon' => 'storages'/s");
->toContain("'route' => 'project.service.import-backup'");
});
@@ -69,6 +69,20 @@ it('declares deploy authorization on the service container removal confirmation'
);
});
it('declares update authorization on service backup mutation controls', function () {
$importBackupView = file_get_contents(resource_path('views/livewire/project/service/import-backup.blade.php'));
$volumeBackupView = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
expect($importBackupView)->toMatch(
'/<x-forms\.listbox(?=[^>]*id="selectedDatabaseUuid")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/'
);
expect($volumeBackupView)
->toMatch('/<x-modal-input(?=[^>]*:title="\'Edit backup schedule\'")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/')
->toMatch('/<x-forms\.button(?=[^>]*wire:click\.stop="backupNow\(\'database\',[^"]+")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/')
->toMatch('/<x-forms\.button(?=[^>]*wire:click\.stop="backupNow\(\'storage\',[^"]+")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$service")[^>]*>/');
});
it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
$source = file_get_contents(base_path($path));
@@ -1,5 +1,7 @@
<?php
use App\Livewire\Project\Service\DatabaseBackups;
it('moves service and database page navigation into their sidebars', function () {
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
@@ -58,7 +60,7 @@ it('groups database and service navigation by user workflow', function () {
expect($serviceSidebar)
->toContain("'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage']")
->toContain("'Observe & troubleshoot' => ['Runtime Logs', 'Terminal']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups']")
->toContain("'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup']")
->toContain("'Operations' => ['Resource Operations', 'Tags', 'Danger Zone']");
}
});
@@ -110,7 +112,7 @@ it('combines service database and storage backups in one section', function () {
->toContain('class="data-table w-full overflow-x-auto"')
->toContain('backup-table-grid service-backup-table-grid')
->not->toContain('<span class="text-right">Executions</span>')
->toContain('class="data-table-row backup-table-grid text-[13px]')
->toMatch('/class="(?=[^"]*\\bdata-table-row\\b)(?=[^"]*\\bbackup-table-grid\\b)(?=[^"]*text-\\[13px\\])[^\"]*"/')
->toContain('class="listbox-option justify-start! gap-2.5!"')
->toContain('x-data="{ dropdownOpen: false }"')
->toContain('class="listbox-panel left-0! right-auto! z-[90]! w-52! min-w-52! sm:left-auto! sm:right-0!"')
@@ -119,15 +121,147 @@ it('combines service database and storage backups in one section', function () {
expect($styles)->toContain('.service-backup-table-grid');
});
it('links compose database backups to the unified service backups page', function () {
it('keeps backup navigation out of compose database settings', function () {
$sidebar = file_get_contents(resource_path('views/components/service-database/sidebar.blade.php'));
expect($sidebar)
->toContain("'route' => 'project.service.volume-backups.index'")
->toContain("'parameters' => \$serviceParameters")
->not->toContain("'label' => 'Backups'")
->not->toContain("'label' => 'Import Backup'")
->not->toContain("'route' => 'project.service.database.backups'");
});
it('links service backup details back to the unified service backups page', function () {
$sidebar = file_get_contents(resource_path('views/components/backup-sidebar.blade.php'));
expect($sidebar)->toContain("'back' => 'project.service.volume-backups.index'");
});
it('declares the database backup mount return type', function () {
$returnType = (new ReflectionMethod(DatabaseBackups::class, 'mount'))->getReturnType();
expect($returnType)->not->toBeNull()
->and($returnType->getName())->toBe('mixed');
});
it('keeps service navigation visible on backup detail pages and uses section tabs', function () {
$databaseBackup = file_get_contents(resource_path('views/livewire/project/service/database-backups.blade.php'));
$storageBackup = file_get_contents(resource_path('views/livewire/project/service/volume-backup/show.blade.php'));
foreach ([$databaseBackup, $storageBackup] as $view) {
expect($view)
->toContain('<x-service.configuration-sidebar :service="$service"')
->toContain('<x-backup-tabs')
->not->toContain('<x-backup-sidebar');
}
expect($databaseBackup)->toContain('context="service"')
->and($storageBackup)->toContain('context="service-volume"');
});
it('opens service backup schedules in place without navigating', function () {
$index = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
expect($index)
->toContain('wire:click="openSchedule(')
->toContain('wireOpen="scheduleModalOpen"')
->toContain('<x-backup-tabs')
->not->toContain("route('project.service.backups.schedule.show'");
});
it('shows service backup executions below schedules without a separate view tab', function () {
$index = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
$executions = file_get_contents(resource_path('views/livewire/project/service/backup-executions.blade.php'));
expect($index)
->toContain('<livewire:project.service.backup-executions :service="$service"')
->not->toContain('aria-label="Backup views"')
->not->toContain("route('project.service.backups.executions'");
expect($executions)
->toContain('title="Executions"')
->toContain('flush>');
});
it('wraps backup modal tabs and shows schedule loading feedback', function () {
$tabs = file_get_contents(resource_path('views/components/backup-tabs.blade.php'));
$index = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
expect($tabs)
->toContain('flex-wrap')
->not->toContain('overflow-x-auto');
expect($index)
->toContain('<x-table.loading target="openSchedule" text="Loading schedule..."')
->not->toContain('Loading schedule\n');
});
it('loads every backup editor section when the modal opens and switches tabs locally', function () {
$tabs = file_get_contents(resource_path('views/components/backup-tabs.blade.php'));
$index = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
expect($index)
->toContain("x-data=\"{ activeSection: 'general' }\"")
->toContain("@foreach (['general', 's3', 'retention', 'danger'] as \$modalSection)")
->toContain("x-show=\"activeSection === '{{ \$modalSection }}'\"");
expect($tabs)
->toContain("@click=\"activeSection = '{{ \$item['key'] }}'\"")
->not->toContain('wire:click="selectScheduleSection');
});
it('adds a back up now action to every service backup schedule row', function () {
$index = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect($index)
->toContain('<span class="text-right">Actions</span>')
->toContain('<div class="min-w-[59rem]">')
->toContain("wire:click.stop=\"backupNow('database',")
->toContain("wire:click.stop=\"backupNow('storage',")
->toContain('<x-forms.button')
->toContain('Back up now</x-forms.button>')
->not->toContain('class="icon-button shrink-0"')
->not->toContain('class="contents cursor-pointer"');
expect($styles)
->toContain('.service-backup-table-grid')
->toContain('width: 100%;')
->toContain('.data-table-row.service-backup-table-grid {')
->not->toContain('.data-table-header.service-backup-table-grid > :last-child')
->not->toContain('.data-table-row.service-backup-table-grid > :last-child');
});
it('refreshes backup executions from backup broadcasts on the current team channel', function () {
$serviceExecutions = file_get_contents(app_path('Livewire/Project/Service/BackupExecutions.php'));
$serviceBackups = file_get_contents(app_path('Livewire/Project/Service/VolumeBackup/Index.php'));
$databaseExecutions = file_get_contents(app_path('Livewire/Project/Database/BackupExecutions.php'));
$databaseBackupJob = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
expect($serviceExecutions)
->toContain('echo-private:team.{$teamId},BackupCreated')
->toContain("=> '\$refresh'")
->and($serviceBackups)
->toContain('echo-private:team.{$teamId},BackupCreated')
->and($databaseExecutions)
->toContain('$teamId = currentTeam()->id')
->not->toContain('$userId = Auth::id()')
->and(strpos($databaseBackupJob, "'finished_at' => Carbon::now()->toImmutable()"))
->toBeLessThan(strrpos($databaseBackupJob, 'BackupCreated::dispatch($this->team->id)'));
});
it('offers downloads from the service backup executions list', function () {
$component = file_get_contents(app_path('Livewire/Project/Service/BackupExecutions.php'));
$view = file_get_contents(resource_path('views/livewire/project/service/backup-executions.blade.php'));
expect($component)
->toContain("route('download.backup'")
->toContain("route('download.volume-backup'")
->and($view)
->toContain('<span class="text-right">Actions</span>')
->toContain('aria-label="Download backup"')
->toContain('@click.stop');
});
it('uses a distinct backup icon across resource sidebars', function () {
$sidebars = [
resource_path('views/components/application/configuration-sidebar.blade.php'),
+260 -47
View File
@@ -1,11 +1,16 @@
<?php
use App\Jobs\DatabaseBackupJob;
use App\Jobs\VolumeBackupJob;
use App\Livewire\Project\Database\Import as DatabaseImport;
use App\Livewire\Project\Service\Heading;
use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -16,6 +21,7 @@ use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -113,6 +119,14 @@ test('does not open service database backups route from another team', function
]));
})->throws(NotFoundHttpException::class);
test('does not open service import backup route from another team', function () {
$this->get(route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->otherService->uuid,
]))->assertForbidden();
});
test('does not resolve service database import component from another team', function () {
$component = app(DatabaseImport::class);
$component->parameters = [
@@ -141,7 +155,7 @@ test('owner can still hydrate service heading with own service', function () {
->assertOk();
});
test('service database backup schedules use dedicated general retention and executions urls', function () {
test('legacy service database backup detail urls redirect to unified backup views', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
@@ -156,51 +170,254 @@ test('service database backup schedules use dedicated general retention and exec
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$generalUrl = $listUrl.'/'.$backup->uuid;
$parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
];
$this->get($listUrl)
->assertOk()
->assertSee('href="'.$generalUrl.'"', false);
$this->get($generalUrl)->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/s3')->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/retention')->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/danger')->assertRedirect(route('project.service.volume-backups.index', $parameters));
$this->get($generalUrl.'/executions')->assertRedirect(route('project.service.volume-backups.index', $parameters));
});
$this->get($generalUrl)
->assertOk()
->assertSee('Frequency')
->assertDontSee('S3 Enabled')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups')
->assertDontSee('Delete Backups and Schedule');
test('legacy service database backup list redirects to unified service backups', function () {
$legacyUrl = route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$centralBackupsUrl = route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]);
$this->get($generalUrl.'/s3')
->assertOk()
->assertSee('S3 Storage')
->assertDontSee('S3 Storage Retention')
->assertDontSee('Local Backup Retention')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
$this->get($legacyUrl)->assertRedirect($centralBackupsUrl);
});
$this->get($generalUrl.'/retention')
->assertOk()
->assertSee('Local Backup Retention')
->assertSee('S3 Storage Retention')
->assertSee('Number of backups to keep')
->assertDontSee('Frequency')
->assertDontSee('Cleanup Failed Backups');
test('service backup schedules open in place from the unified view', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$this->get($generalUrl.'/executions')
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))
->assertOk()
->assertSee('<h2 class="py-0">Executions</h2>', false)
->assertDontSee('Executions <span', false)
->assertSee('Cleanup Failed Backups')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep');
->assertSee("wire:click=\"openSchedule('{$backup->uuid}')\"", false);
});
$this->get($generalUrl.'/danger')
test('service database backup schedules open in the Livewire component', function () {
Queue::fake();
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->call('openSchedule', $backup->uuid)
->assertSet('scheduleModalOpen', true)
->assertSet('selectedDatabaseBackup.uuid', $backup->uuid)
->assertSet('selectedVolumeBackup', null);
});
test('service database backups can be queued from the Livewire component', function () {
Queue::fake();
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->call('backupNow', 'database', $backup->uuid)
->assertDispatched('success', 'Backup queued.');
Queue::assertPushed(DatabaseBackupJob::class, fn (DatabaseBackupJob $job): bool => $job->backup->is($backup));
});
test('service storage backups can be queued from the Livewire component', function () {
Queue::fake();
$volume = LocalPersistentVolume::create([
'name' => 'service-data',
'mount_path' => '/data',
'resource_id' => $this->ownServiceDatabase->id,
'resource_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$backup = $volume->scheduledBackups()->create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
]);
Livewire::test(ServiceVolumeBackupIndex::class, ['service' => $this->ownService])
->call('backupNow', 'storage', $backup->uuid)
->assertDispatched('success', 'Backup queued.');
Queue::assertPushed(VolumeBackupJob::class, fn (VolumeBackupJob $job): bool => $job->backup->is($backup));
});
test('service backup executions combine database execution history', function () {
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'frequency' => 'daily',
'database_id' => $this->ownServiceDatabase->id,
'database_type' => $this->ownServiceDatabase->getMorphClass(),
]);
$execution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'database_name' => 'coolify',
'size' => 2048,
'finished_at' => now(),
]);
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))
->assertOk()
->assertSee('Danger Zone')
->assertSee('Delete Scheduled Backup')
->assertSee('Delete Backups and Schedule')
->assertDontSee('Frequency')
->assertDontSee('Number of backups to keep')
->assertDontSee('Cleanup Failed Backups');
->assertSee('own-db')
->assertSee('Success')
->assertSee('2 KB');
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))->assertSee("wire:click=\"openExecution('{$execution->uuid}')\"", false);
});
test('service import backup page selects from compatible databases', function () {
$secondDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'analytics-db',
'image' => 'mysql:8',
'custom_type' => 'mysql',
]);
$importUrl = route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]);
$this->get($importUrl)
->assertOk()
->assertSee('Import Backup')
->assertSee('own-db')
->assertSee('analytics-db');
$this->get($importUrl.'/'.$secondDatabase->uuid)
->assertOk()
->assertSee('analytics-db')
->assertSee('Start the database first');
});
test('service import backup redirects when exactly one compatible database exists', function () {
$this->get(route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))->assertRedirectToRoute('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
});
test('service import backup excludes unsupported databases', function () {
$unsupportedDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'cache-db',
'image' => 'redis:7-alpine',
'custom_type' => 'redis',
]);
$this->get(route('project.service.import-backup', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
]))
->assertRedirectToRoute('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$this->get(route('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $unsupportedDatabase->uuid,
]))->assertNotFound();
});
test('service import backup opens the selected compatible database', function () {
$secondDatabase = ServiceDatabase::create([
'service_id' => $this->ownService->id,
'name' => 'analytics-db',
'image' => 'mysql:8',
'custom_type' => 'mysql',
]);
$this->get(route('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $secondDatabase->uuid,
]))
->assertOk()
->assertSee('analytics-db')
->assertSee('Start the database first');
});
test('service import backup requires update authorization for the service and selected database', function () {
$member = User::factory()->create();
$member->teams()->attach($this->teamA, ['role' => 'member']);
$this->actingAs($member);
$parameters = [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
];
$this->get(route('project.service.import-backup', $parameters))->assertForbidden();
$this->get(route('project.service.import-backup.database', [
...$parameters,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]))->assertForbidden();
});
test('legacy service database import redirects to the service import page with its database selected', function () {
$legacyUrl = route('project.service.database.import', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$selectedImportUrl = route('project.service.import-backup.database', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]);
$this->get($legacyUrl)->assertRedirect($selectedImportUrl);
});
test('service storage backups page includes schedules from all compose databases', function () {
@@ -211,15 +428,15 @@ test('service storage backups page includes schedules from all compose databases
'custom_type' => 'postgresql',
]);
foreach ([$this->ownServiceDatabase, $secondDatabase] as $database) {
ScheduledDatabaseBackup::create([
$backups = collect([$this->ownServiceDatabase, $secondDatabase])->map(function (ServiceDatabase $database) {
return ScheduledDatabaseBackup::create([
'team_id' => $this->teamA->id,
'description' => $database->name.' backup',
'frequency' => 'daily',
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
]);
}
});
$this->get(route('project.service.volume-backups.index', [
'project_uuid' => $this->projectA->uuid,
@@ -230,10 +447,6 @@ test('service storage backups page includes schedules from all compose databases
->assertSee('>Database</span>', false)
->assertSee('own-db')
->assertSee('analytics-db')
->assertSee(route('project.service.database.backups', [
'project_uuid' => $this->projectA->uuid,
'environment_uuid' => $this->environmentA->uuid,
'service_uuid' => $this->ownService->uuid,
'stack_service_uuid' => $this->ownServiceDatabase->uuid,
]), false);
->assertSee("wire:click=\"openSchedule('{$backups->first()->uuid}')\"", false)
->assertSee("wire:click=\"backupNow('database', '{$backups->first()->uuid}')\"", false);
});
+10
View File
@@ -7,6 +7,7 @@ use App\Jobs\VolumeBackupRecoveryJob;
use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBackup;
use App\Livewire\Project\Service\FileStorage;
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
use App\Livewire\Project\Shared\Storages\Show;
use App\Livewire\Project\Shared\Storages\VolumeBackups;
use App\Models\Application;
@@ -26,6 +27,7 @@ use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\RedirectResponse;
use Illuminate\Queue\Middleware\WithoutOverlapping;
@@ -44,6 +46,14 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
it('types service backup S3 storage state as a nullable Eloquent collection', function () {
$property = new ReflectionProperty(ServiceVolumeBackupIndex::class, 's3s');
expect($property->getType()?->getName())->toBe(Collection::class)
->and($property->getType()?->allowsNull())->toBeTrue()
->and($property->getDefaultValue())->toBeNull();
});
it('provides the volume backup domain classes and relationship', function () {
expect(class_exists(ScheduledVolumeBackup::class))->toBeTrue()
->and(class_exists(ScheduledVolumeBackupExecution::class))->toBeTrue()