mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 15:30:03 -05:00
feat(backups): unify service backup management (#11574)
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user