Edit & Download (local) Backups (#436)

* Allow editing of backups

* pint updates

* setup of backup download

* allow download for local backup files

* delete uploaded files on delete of BackupFile

* pint updates

* S3 upload & download fixes

* Deletion of backup files

* support $ARCH selector for s3 installation

* delete files when deleting backup

* fixed ui issue

* adjustment

* Use system temp path for downloads

---------

Co-authored-by: Saeed Vaziry <mr.saeedvaziry@gmail.com>
This commit is contained in:
Richard Anderson 2025-01-25 20:59:35 +00:00 committed by GitHub
parent 465951fd1e
commit a73476c1dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 382 additions and 184 deletions

View File

@ -2,6 +2,7 @@
namespace App\Actions\Database;
use App\Enums\BackupFileStatus;
use App\Enums\BackupStatus;
use App\Enums\DatabaseStatus;
use App\Models\Backup;
@ -10,7 +11,7 @@
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class CreateBackup
class ManageBackup
{
/**
* @throws AuthorizationException
@ -34,6 +35,31 @@ public function create(Server $server, array $input): Backup
return $backup;
}
public function update(Backup $backup, array $input): void
{
$backup->interval = $input['interval'] == 'custom' ? $input['custom_interval'] : $input['interval'];
$backup->keep_backups = $input['keep'];
$backup->save();
}
public function delete(Backup $backup): void
{
$backup->status = BackupStatus::DELETING;
$backup->save();
dispatch(function () use ($backup) {
$files = $backup->files;
foreach ($files as $file) {
$file->status = BackupFileStatus::DELETING;
$file->save();
$file->deleteFile();
}
$backup->delete();
});
}
public static function rules(Server $server, array $input): array
{
$rules = [

View File

@ -0,0 +1,39 @@
<?php
namespace App\Actions\Database;
use App\Enums\BackupFileStatus;
use App\Models\BackupFile;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class ManageBackupFile
{
/**
* @throws Throwable
*/
public function download(BackupFile $file): StreamedResponse
{
$localFilename = "backup_{$file->id}_{$file->name}.zip";
if (! Storage::disk('backups')->exists($localFilename)) {
$file->backup->server->ssh()->download(
Storage::disk('backups')->path($localFilename),
$file->path()
);
}
return Storage::disk('backups')->download($localFilename, $file->name.'.zip');
}
public function delete(BackupFile $file): void
{
$file->status = BackupFileStatus::DELETING;
$file->save();
dispatch(function () use ($file) {
$file->deleteFile();
});
}
}

View File

@ -7,4 +7,6 @@ final class BackupStatus
const RUNNING = 'running';
const FAILED = 'failed';
const DELETING = 'deleting';
}

View File

@ -181,7 +181,7 @@ public function download(string $local, string $remote): void
$this->connect(true);
}
$this->connection->get($remote, $local, SFTP::SOURCE_LOCAL_FILE);
$this->connection->get($remote, $local);
}
/**

View File

@ -56,8 +56,17 @@ public static function boot(): void
public static array $statusColors = [
BackupStatus::RUNNING => 'success',
BackupStatus::FAILED => 'danger',
BackupStatus::DELETING => 'warning',
];
public function isCustomInterval(): bool
{
$intervals = array_keys(config('core.cronjob_intervals'));
$intervals = array_filter($intervals, fn ($interval) => $interval !== 'custom');
return ! in_array($this->interval, $intervals);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);

View File

@ -2,7 +2,9 @@
namespace App\Models;
use App\Actions\Database\ManageBackupFile;
use App\Enums\BackupFileStatus;
use App\Enums\StorageProvider as StorageProviderAlias;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -46,19 +48,11 @@ protected static function booted(): void
->where('id', '<=', $lastFileToKeep->id)
->get();
foreach ($files as $file) {
$file->delete();
app(ManageBackupFile::class)->delete($file);
}
}
}
});
static::deleting(function (BackupFile $backupFile) {
$provider = $backupFile->backup->storage->provider();
$path = $backupFile->storagePath();
dispatch(function () use ($provider, $path) {
$provider->delete([$path]);
});
});
}
public static array $statusColors = [
@ -71,18 +65,52 @@ protected static function booted(): void
BackupFileStatus::RESTORE_FAILED => 'danger',
];
public function isAvailable(): bool
{
return ! in_array(
$this->status,
[BackupFileStatus::CREATING, BackupFileStatus::FAILED, BackupFileStatus::DELETING]
);
}
public function isLocal(): bool
{
return $this->backup->storage->provider === StorageProviderAlias::LOCAL;
}
public function backup(): BelongsTo
{
return $this->belongsTo(Backup::class);
}
public function path(): string
public function tempPath(): string
{
return '/home/'.$this->backup->server->getSshUser().'/'.$this->name.'.zip';
}
public function storagePath(): string
public function path(): string
{
return '/'.$this->backup->database->name.'/'.$this->name.'.zip';
$storage = $this->backup->storage;
$databaseName = $this->backup->database->name;
return match ($storage->provider) {
StorageProviderAlias::DROPBOX => '/'.$databaseName.'/'.$this->name.'.zip',
StorageProviderAlias::S3, StorageProviderAlias::FTP, StorageProviderAlias::LOCAL => implode('/', [
rtrim($storage->credentials['path'], '/'),
$databaseName,
$this->name.'.zip',
]),
default => '',
};
}
public function deleteFile(): void
{
try {
$storage = $this->backup->storage->provider()->ssh($this->backup->server);
$storage->delete($this->path());
} finally {
$this->delete();
}
}
}

View File

@ -243,6 +243,16 @@ public function resourceInfo(): array
];
}
public function deleteFile(string $path): void
{
$this->server->ssh()->exec(
$this->getScript('delete-file.sh', [
'path' => $path,
]),
'delete-file'
);
}
private function deleteTempFile(string $name): void
{
if (Storage::disk('local')->exists($name)) {

View File

@ -0,0 +1 @@
rm -f __path__

View File

@ -159,12 +159,12 @@ public function runBackup(BackupFile $backupFile): void
// upload to storage
$upload = $backupFile->backup->storage->provider()->ssh($this->service->server)->upload(
$backupFile->tempPath(),
$backupFile->path(),
$backupFile->storagePath(),
);
// cleanup
$this->service->server->ssh()->exec('rm '.$backupFile->path());
$this->service->server->ssh()->exec('rm '.$backupFile->tempPath());
$backupFile->size = $upload['size'];
$backupFile->save();
@ -174,14 +174,14 @@ public function restoreBackup(BackupFile $backupFile, string $database): void
{
// download
$backupFile->backup->storage->provider()->ssh($this->service->server)->download(
$backupFile->storagePath(),
$backupFile->name.'.zip',
$backupFile->path(),
$backupFile->tempPath(),
);
$this->service->server->ssh()->exec(
$this->getScript($this->getScriptsDir().'/restore.sh', [
'database' => $database,
'file' => $backupFile->name,
'file' => rtrim($backupFile->tempPath(), '.zip'),
]),
'restore-database'
);

View File

@ -45,11 +45,14 @@ public function download(string $src, string $dest): void
);
}
/**
* @TODO Implement delete method
*/
public function delete(string $path): void
public function delete(string $src): void
{
//
$this->server->ssh()->exec(
$this->getScript('dropbox/delete-file.sh', [
'src' => $src,
'token' => $this->storageProvider->credentials['token'],
]),
'delete-from-dropbox'
);
}
}

View File

@ -13,7 +13,7 @@ public function upload(string $src, string $dest): array
$this->server->ssh()->exec(
$this->getScript('ftp/upload.sh', [
'src' => $src,
'dest' => $this->storageProvider->credentials['path'].'/'.$dest,
'dest' => $dest,
'host' => $this->storageProvider->credentials['host'],
'port' => $this->storageProvider->credentials['port'],
'username' => $this->storageProvider->credentials['username'],
@ -33,7 +33,7 @@ public function download(string $src, string $dest): void
{
$this->server->ssh()->exec(
$this->getScript('ftp/download.sh', [
'src' => $this->storageProvider->credentials['path'].'/'.$src,
'src' => $src,
'dest' => $dest,
'host' => $this->storageProvider->credentials['host'],
'port' => $this->storageProvider->credentials['port'],
@ -46,11 +46,19 @@ public function download(string $src, string $dest): void
);
}
/**
* @TODO Implement delete method
*/
public function delete(string $path): void
public function delete(string $src): void
{
//
$this->server->ssh()->exec(
$this->getScript('ftp/delete-file.sh', [
'src' => $src,
'host' => $this->storageProvider->credentials['host'],
'port' => $this->storageProvider->credentials['port'],
'username' => $this->storageProvider->credentials['username'],
'password' => $this->storageProvider->credentials['password'],
'ssl' => $this->storageProvider->credentials['ssl'],
'passive' => $this->storageProvider->credentials['passive'],
]),
'delete-from-ftp'
);
}
}

View File

@ -10,13 +10,12 @@ class Local extends AbstractStorage
public function upload(string $src, string $dest): array
{
$destDir = dirname($this->storageProvider->credentials['path'].$dest);
$destFile = basename($this->storageProvider->credentials['path'].$dest);
$destDir = dirname($dest);
$this->server->ssh()->exec(
$this->getScript('local/upload.sh', [
'src' => $src,
'dest_dir' => $destDir,
'dest_file' => $destFile,
'dest_file' => $dest,
]),
'upload-to-local'
);
@ -30,20 +29,15 @@ public function download(string $src, string $dest): void
{
$this->server->ssh()->exec(
$this->getScript('local/download.sh', [
'src' => $this->storageProvider->credentials['path'].$src,
'src' => $src,
'dest' => $dest,
]),
'download-from-local'
);
}
public function delete(string $path): void
public function delete(string $src): void
{
$this->server->ssh()->exec(
$this->getScript('local/delete.sh', [
'path' => $this->storageProvider->credentials['path'].$path,
]),
'delete-from-local'
);
$this->server->os()->deleteFile($src);
}
}

View File

@ -23,7 +23,7 @@ public function upload(string $src, string $dest): array
$uploadCommand = $this->getScript('s3/upload.sh', [
'src' => $src,
'bucket' => $this->storageProvider->credentials['bucket'],
'dest' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$dest),
'dest' => $this->prepareS3Path($dest),
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->storageProvider->credentials['region'],
@ -52,7 +52,7 @@ public function download(string $src, string $dest): void
$provider = $this->storageProvider->provider();
$downloadCommand = $this->getScript('s3/download.sh', [
'src' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$src),
'src' => $this->prepareS3Path($src),
'dest' => $dest,
'bucket' => $this->storageProvider->credentials['bucket'],
'key' => $this->storageProvider->credentials['key'],
@ -71,8 +71,21 @@ public function download(string $src, string $dest): void
}
}
/**
* @TODO Implement delete method
*/
public function delete(string $path): void {}
public function delete(string $src): void
{
/** @var \App\StorageProviders\S3 $provider */
$provider = $this->storageProvider->provider();
$this->server->ssh()->exec(
$this->getScript('s3/delete-file.sh', [
'src' => $this->prepareS3Path($src),
'bucket' => $this->storageProvider->credentials['bucket'],
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->storageProvider->credentials['region'],
'endpoint' => $provider->getApiUrl(),
]),
'delete-from-s3'
);
}
}

View File

@ -8,5 +8,5 @@ public function upload(string $src, string $dest): array;
public function download(string $src, string $dest): void;
public function delete(string $path): void;
public function delete(string $src): void;
}

View File

@ -0,0 +1,6 @@
curl --location --request POST 'https://api.dropboxapi.com/2/files/delete_v2' \
--header 'Authorization: Bearer __token__' \
--header 'Content-Type: application/json' \
--data-raw '{
"path": "__src__"
}'

View File

@ -0,0 +1 @@
curl __passive__ -u "__username__:__password__" ftp__ssl__://__host__:__port__/__src__ -Q "DELE /__src__"

View File

@ -1 +0,0 @@
rm __path__

View File

@ -1,2 +1,2 @@
mkdir -p __dest_dir__
cp __src__ __dest_dir__/__dest_file__
cp __src__ __dest_file__

View File

@ -0,0 +1,30 @@
#!/bin/bash
command_exists() {
command -v "$1" >/dev/null 2>&1
}
install_aws_cli() {
echo "Installing AWS CLI"
ARCH=$(uname -m)
curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip"
unzip -q aws.zip
sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
rm -rf aws.zip aws
}
if ! command_exists aws; then
install_aws_cli
fi
if ! command_exists aws; then
echo "Error: AWS CLI installation failed"
exit 1
fi
export AWS_ACCESS_KEY_ID=__key__
export AWS_SECRET_ACCESS_KEY=__secret__
export AWS_DEFAULT_REGION=__region__
export AWS_ENDPOINT_URL=__endpoint__
aws s3 rm s3://__bucket__/__src__

View File

@ -1,32 +1,32 @@
#!/bin/bash
# Configure AWS CLI with provided credentials
/usr/local/bin/aws configure set aws_access_key_id "__key__"
/usr/local/bin/aws configure set aws_secret_access_key "__secret__"
/usr/local/bin/aws configure set default.region "__region__"
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Use the provided endpoint in the correct format
ENDPOINT="__endpoint__"
BUCKET="__bucket__"
REGION="__region__"
install_aws_cli() {
echo "Installing AWS CLI"
ARCH=$(uname -m)
curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip"
unzip -q aws.zip
sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
rm -rf aws.zip aws
}
# Ensure that DEST does not have a trailing slash
SRC="__src__"
DEST="__dest__"
if ! command_exists aws; then
install_aws_cli
fi
# Download the file from S3
echo "Downloading s3://__bucket__/__src__ to __dest__"
download_output=$(/usr/local/bin/aws s3 cp "s3://$BUCKET/$SRC" "$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
download_exit_code=$?
# Log output and exit code
echo "Download command output: $download_output"
echo "Download command exit code: $download_exit_code"
# Check if the download was successful
if [ $download_exit_code -eq 0 ]; then
echo "Download successful"
else
echo "Download failed"
if ! command_exists aws; then
echo "Error: AWS CLI installation failed"
exit 1
fi
export AWS_ACCESS_KEY_ID=__key__
export AWS_SECRET_ACCESS_KEY=__secret__
export AWS_DEFAULT_REGION=__region__
export AWS_ENDPOINT_URL=__endpoint__
if aws s3 cp s3://__bucket__/__src__ __dest__; then
echo "Download successful"
fi

View File

@ -1,59 +1,32 @@
#!/bin/bash
# Check if AWS CLI is installed
if ! command -v aws &> /dev/null
then
echo "AWS CLI is not installed. Installing..."
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Detect system architecture
install_aws_cli() {
echo "Installing AWS CLI"
ARCH=$(uname -m)
if [ "$ARCH" == "x86_64" ]; then
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"
elif [ "$ARCH" == "aarch64" ]; then
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"
else
echo "Unsupported architecture: $ARCH"
exit 1
fi
curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip"
unzip -q aws.zip
sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update
rm -rf aws.zip aws
}
# Download and install AWS CLI
sudo curl "$CLI_URL" -o "awscliv2.zip"
sudo unzip awscliv2.zip
sudo ./aws/install --update
sudo rm -rf awscliv2.zip aws
echo "AWS CLI installation completed."
else
echo "AWS CLI is already installed."
/usr/local/bin/aws --version
if ! command_exists aws; then
install_aws_cli
fi
# Configure AWS CLI with provided credentials
/usr/local/bin/aws configure set aws_access_key_id "__key__"
/usr/local/bin/aws configure set aws_secret_access_key "__secret__"
# Use the provided endpoint in the correct format
ENDPOINT="__endpoint__"
BUCKET="__bucket__"
REGION="__region__"
# Ensure that DEST does not have a trailing slash
SRC="__src__"
DEST="__dest__"
# Upload the file
echo "Uploading __src__ to s3://$BUCKET/$DEST"
upload_output=$(/usr/local/bin/aws s3 cp "$SRC" "s3://$BUCKET/$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
upload_exit_code=$?
# Log output and exit code
echo "Upload command output: $upload_output"
echo "Upload command exit code: $upload_exit_code"
# Check if the upload was successful
if [ $upload_exit_code -eq 0 ]; then
echo "Upload successful"
else
echo "Upload failed"
if ! command_exists aws; then
echo "Error: AWS CLI installation failed"
exit 1
fi
export AWS_ACCESS_KEY_ID=__key__
export AWS_SECRET_ACCESS_KEY=__secret__
export AWS_DEFAULT_REGION=__region__
export AWS_ENDPOINT_URL=__endpoint__
if aws s3 cp __src__ s3://__bucket__/__dest__; then
echo "Upload successful"
fi

View File

@ -38,19 +38,4 @@ public function ssh(Server $server): Storage
{
return new \App\SSH\Storage\Dropbox($server, $this->storageProvider);
}
public function delete(array $paths): void
{
$data = [];
foreach ($paths as $path) {
$data[] = ['path' => $path];
}
Http::withToken($this->storageProvider->credentials['token'])
->withHeaders([
'Content-Type:application/json',
])
->post($this->apiUrl.'/files/delete_batch', [
'entries' => $data,
]);
}
}

View File

@ -57,23 +57,6 @@ public function ssh(Server $server): Storage
return new \App\SSH\Storage\FTP($server, $this->storageProvider);
}
public function delete(array $paths): void
{
$connection = $this->connection();
if ($connection && $this->login($connection)) {
if ($this->storageProvider->credentials['passive']) {
\App\Facades\FTP::passive($connection, true);
}
foreach ($paths as $path) {
\App\Facades\FTP::delete($connection, $this->storageProvider->credentials['path'].'/'.$path);
}
}
\App\Facades\FTP::close($connection);
}
private function connection(): bool|Connection
{
$credentials = $this->storageProvider->credentials;

View File

@ -30,9 +30,4 @@ public function ssh(Server $server): Storage
{
return new \App\SSH\Storage\Local($server, $this->storageProvider);
}
public function delete(array $paths): void
{
//
}
}

View File

@ -95,6 +95,4 @@ public function ssh(Server $server): Storage
{
return new S3Storage($server, $this->storageProvider);
}
public function delete(array $paths): void {}
}

View File

@ -14,6 +14,4 @@ public function credentialData(array $input): array;
public function connect(): bool;
public function ssh(Server $server): Storage;
public function delete(array $paths): void;
}

View File

@ -2,7 +2,7 @@
namespace App\Web\Pages\Servers\Databases;
use App\Actions\Database\CreateBackup;
use App\Actions\Database\ManageBackup;
use App\Models\Backup;
use App\Models\StorageProvider;
use App\Web\Contracts\HasSecondSubNav;
@ -38,12 +38,12 @@ protected function getHeaderActions(): array
Select::make('database')
->label('Database')
->options($this->server->databases()->pluck('name', 'id')->toArray())
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['database'])
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['database'])
->searchable(),
Select::make('storage')
->label('Storage')
->options(StorageProvider::getByProjectId($this->server->project_id)->pluck('profile', 'id')->toArray())
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['storage'])
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['storage'])
->suffixAction(
\Filament\Forms\Components\Actions\Action::make('connect')
->form(Create::form())
@ -59,21 +59,21 @@ protected function getHeaderActions(): array
->label('Interval')
->options(config('core.cronjob_intervals'))
->reactive()
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['interval']),
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['interval']),
TextInput::make('custom_interval')
->label('Custom Interval (Cron)')
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['custom_interval'])
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['custom_interval'])
->visible(fn (callable $get) => $get('interval') === 'custom')
->placeholder('0 * * * *'),
TextInput::make('keep')
->label('Backups to Keep')
->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['keep'])
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['keep'])
->helperText('How many backups to keep before deleting the oldest one'),
])
->modalSubmitActionLabel('Create')
->action(function (array $data) {
run_action($this, function () use ($data) {
app(CreateBackup::class)->create($this->server, $data);
app(ManageBackup::class)->create($this->server, $data);
$this->dispatch('$refresh');

View File

@ -2,6 +2,7 @@
namespace App\Web\Pages\Servers\Databases\Widgets;
use App\Actions\Database\ManageBackupFile;
use App\Actions\Database\RestoreBackup;
use App\Models\Backup;
use App\Models\BackupFile;
@ -59,11 +60,21 @@ public function table(Table $table): Table
->query($this->getTableQuery())
->columns($this->getTableColumns())
->actions([
Action::make('download')
->hiddenLabel()
->icon('heroicon-o-arrow-down-tray')
->visible(fn (BackupFile $record) => $record->isAvailable() && $record->isLocal())
->tooltip('Download')
->action(function (BackupFile $record) {
return app(ManageBackupFile::class)->download($record);
})
->authorize(fn (BackupFile $record) => auth()->user()->can('view', $record)),
Action::make('restore')
->hiddenLabel()
->icon('heroicon-o-arrow-path')
->modalHeading('Restore Backup')
->tooltip('Restore Backup')
->disabled(fn (BackupFile $record) => ! $record->isAvailable())
->authorize(fn (BackupFile $record) => auth()->user()->can('update', $record->backup))
->form([
Select::make('database')
@ -95,16 +106,15 @@ public function table(Table $table): Table
Action::make('delete')
->hiddenLabel()
->icon('heroicon-o-trash')
->modalHeading('Delete Database')
->modalHeading('Delete Backup File')
->color('danger')
->disabled(fn (BackupFile $record) => ! $record->isAvailable())
->tooltip('Delete')
->authorize(fn (BackupFile $record) => auth()->user()->can('delete', $record))
->requiresConfirmation()
->action(function (BackupFile $record) {
run_action($this, function () use ($record) {
$record->delete();
$this->dispatch('$refresh');
});
app(ManageBackupFile::class)->delete($record);
$this->dispatch('$refresh');
}),
]);
}

View File

@ -2,10 +2,14 @@
namespace App\Web\Pages\Servers\Databases\Widgets;
use App\Actions\Database\ManageBackup;
use App\Actions\Database\RunBackup;
use App\Models\Backup;
use App\Models\BackupFile;
use App\Models\Server;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Support\Enums\MaxWidth;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
@ -57,12 +61,53 @@ public function table(Table $table): Table
->query($this->getTableQuery())
->columns($this->getTableColumns())
->actions([
Action::make('edit')
->hiddenLabel()
->icon('heroicon-o-pencil')
->tooltip('Edit Configuration')
->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed']))
->authorize(fn (Backup $record) => auth()->user()->can('update', $record))
->modelLabel('Edit Backup')
->modalWidth(MaxWidth::Large)
->modalSubmitActionLabel('Update')
->form([
Select::make('interval')
->label('Interval')
->options(config('core.cronjob_intervals'))
->reactive()
->default(fn (Backup $record) => $record->isCustomInterval() ? 'custom' : $record->interval)
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['interval']),
TextInput::make('custom_interval')
->label('Custom Interval (Cron)')
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['custom_interval'])
->visible(fn (callable $get) => $get('interval') === 'custom')
->default(fn (Backup $record) => $record->isCustomInterval() ? $record->interval : '')
->placeholder('0 * * * *'),
TextInput::make('keep')
->label('Backups to Keep')
->default(fn (Backup $record) => $record->keep_backups)
->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['keep'])
->helperText('How many backups to keep before deleting the oldest one'),
])
->action(function (Backup $backup, array $data) {
run_action($this, function () use ($data, $backup) {
app(ManageBackup::class)->update($backup, $data);
$this->dispatch('$refresh');
Notification::make()
->success()
->title('Backup updated!')
->send();
});
}),
Action::make('files')
->hiddenLabel()
->icon('heroicon-o-rectangle-stack')
->modalHeading('Backup Files')
->color('gray')
->tooltip('Show backup files')
->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed']))
->authorize(fn (Backup $record) => auth()->user()->can('viewAny', [BackupFile::class, $record]))
->modalContent(fn (Backup $record) => view('components.dynamic-widget', [
'widget' => BackupFilesList::class,
@ -88,16 +133,16 @@ public function table(Table $table): Table
Action::make('delete')
->hiddenLabel()
->icon('heroicon-o-trash')
->modalHeading('Delete Database')
->modalHeading('Delete Backup & Files')
->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed']))
->color('danger')
->tooltip('Delete')
->authorize(fn (Backup $record) => auth()->user()->can('delete', $record))
->requiresConfirmation()
->action(function (Backup $record) {
run_action($this, function () use ($record) {
$record->delete();
$this->dispatch('$refresh');
});
app(ManageBackup::class)->delete($record);
$this->dispatch('$refresh');
}),
]);
}

View File

@ -62,6 +62,11 @@
'root' => storage_path('app/key-pairs'),
],
'backups' => [
'driver' => 'local',
'root' => sys_get_temp_dir(),
],
// @deprecated
'key-pairs-local' => [
'driver' => 'local',

View File

@ -115,6 +115,43 @@ public function test_see_backups_list(): void
->assertSee($backup->database->name);
}
public function test_update_backup(): void
{
$this->actingAs($this->user);
$database = Database::factory()->create([
'server_id' => $this->server,
]);
$storage = StorageProvider::factory()->create([
'user_id' => $this->user->id,
'provider' => \App\Enums\StorageProvider::DROPBOX,
]);
$backup = Backup::factory()->create([
'server_id' => $this->server->id,
'database_id' => $database->id,
'storage_id' => $storage->id,
'interval' => '0 * * * *',
'keep_backups' => 5,
]);
Livewire::test(BackupsList::class, [
'server' => $this->server,
])
->callTableAction('edit', $backup->id, [
'interval' => '0 0 * * *',
'keep' => '10',
])
->assertSuccessful();
$this->assertDatabaseHas('backups', [
'id' => $backup->id,
'interval' => '0 0 * * *',
'keep_backups' => 10,
]);
}
public function test_delete_backup(): void
{
$this->actingAs($this->user);