1617 lines
70 KiB
PHP
1617 lines
70 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Admin\Pages;
|
|
|
|
use App\Models\Backup;
|
|
use App\Models\BackupDestination;
|
|
use App\Models\BackupSchedule;
|
|
use App\Models\User;
|
|
use App\Services\Agent\AgentClient;
|
|
use BackedEnum;
|
|
use Exception;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\Concerns\InteractsWithActions;
|
|
use Filament\Actions\Contracts\HasActions;
|
|
use Filament\Forms\Components\Radio;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Forms\Concerns\InteractsWithForms;
|
|
use Filament\Forms\Contracts\HasForms;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Actions as FormActions;
|
|
use Filament\Schemas\Components\Grid;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Components\Tabs;
|
|
use Filament\Schemas\Components\Tabs\Tab;
|
|
use Filament\Schemas\Components\View;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Columns\ViewColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Contracts\Support\Htmlable;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Livewire\Attributes\Url;
|
|
|
|
class Backups extends Page implements HasActions, HasForms, HasTable
|
|
{
|
|
use InteractsWithActions;
|
|
use InteractsWithForms;
|
|
use InteractsWithTable;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cloud-arrow-up';
|
|
|
|
protected static ?int $navigationSort = 11;
|
|
|
|
public static function getNavigationLabel(): string
|
|
{
|
|
return __('Backups');
|
|
}
|
|
|
|
protected string $view = 'filament.admin.pages.backups';
|
|
|
|
#[Url(as: 'tab')]
|
|
public ?string $activeTab = 'destinations';
|
|
|
|
protected ?AgentClient $agent = null;
|
|
|
|
public function getTitle(): string|Htmlable
|
|
{
|
|
return __('Server Backups');
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->activeTab = $this->normalizeTabName($this->activeTab);
|
|
}
|
|
|
|
protected function normalizeTabName(?string $tab): string
|
|
{
|
|
return match ($tab) {
|
|
'destinations', 'schedules', 'backups' => $tab,
|
|
default => 'destinations',
|
|
};
|
|
}
|
|
|
|
public function setTab(string $tab): void
|
|
{
|
|
$this->activeTab = $this->normalizeTabName($tab);
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function updatedActiveTab(): void
|
|
{
|
|
$this->activeTab = $this->normalizeTabName($this->activeTab);
|
|
$this->resetTable();
|
|
}
|
|
|
|
protected function getForms(): array
|
|
{
|
|
return [
|
|
'backupsForm',
|
|
];
|
|
}
|
|
|
|
public function backupsForm(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make(__('Recommendation'))
|
|
->description(__('Use Incremental Backups for scheduled server backups. They only store changes since the last backup, significantly reducing storage space and backup time while maintaining full restore capability.'))
|
|
->icon('heroicon-o-light-bulb')
|
|
->iconColor('info')
|
|
->collapsed(false)
|
|
->collapsible(false),
|
|
Tabs::make(__('Backup Sections'))
|
|
->contained()
|
|
->livewireProperty('activeTab')
|
|
->tabs([
|
|
'destinations' => Tab::make(__('Destinations'))
|
|
->icon('heroicon-o-server-stack')
|
|
->schema([
|
|
View::make('filament.admin.pages.backups-tab-table'),
|
|
]),
|
|
'schedules' => Tab::make(__('Schedules'))
|
|
->icon('heroicon-o-calendar-days')
|
|
->schema([
|
|
View::make('filament.admin.pages.backups-tab-table'),
|
|
]),
|
|
'backups' => Tab::make(__('Backups'))
|
|
->icon('heroicon-o-archive-box')
|
|
->schema([
|
|
View::make('filament.admin.pages.backups-tab-table'),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public function getAgent(): AgentClient
|
|
{
|
|
return $this->agent ??= new AgentClient;
|
|
}
|
|
|
|
protected function supportsIncremental($destinationId): bool
|
|
{
|
|
if (empty($destinationId)) {
|
|
return false;
|
|
}
|
|
|
|
$destination = BackupDestination::find($destinationId);
|
|
if (! $destination) {
|
|
return false;
|
|
}
|
|
|
|
return in_array($destination->type, ['sftp', 'nfs']);
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return match ($this->activeTab) {
|
|
'destinations' => $this->destinationsTable($table),
|
|
'schedules' => $this->schedulesTable($table),
|
|
'backups' => $this->backupsTable($table),
|
|
default => $this->destinationsTable($table),
|
|
};
|
|
}
|
|
|
|
protected function destinationsTable(Table $table): Table
|
|
{
|
|
return $table
|
|
->query(BackupDestination::query()->where('is_server_backup', true)->orderBy('name'))
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label(__('Name'))
|
|
->weight('medium')
|
|
->description(fn (BackupDestination $record): ?string => $record->is_default ? __('Default') : null)
|
|
->searchable(),
|
|
TextColumn::make('type')
|
|
->label(__('Type'))
|
|
->badge()
|
|
->formatStateUsing(fn (string $state): string => strtoupper($state))
|
|
->color(fn (string $state): string => match ($state) {
|
|
'sftp' => 'info',
|
|
'nfs' => 'warning',
|
|
's3' => 'success',
|
|
default => 'gray',
|
|
}),
|
|
TextColumn::make('test_status')
|
|
->label(__('Status'))
|
|
->badge()
|
|
->formatStateUsing(fn (?string $state): string => match ($state) {
|
|
'success' => __('Connected'),
|
|
'failed' => __('Failed'),
|
|
default => __('Not Tested'),
|
|
})
|
|
->color(fn (?string $state): string => match ($state) {
|
|
'success' => 'success',
|
|
'failed' => 'danger',
|
|
default => 'gray',
|
|
}),
|
|
TextColumn::make('last_tested_at')
|
|
->label(__('Last Tested'))
|
|
->since()
|
|
->placeholder(__('Never'))
|
|
->color('gray'),
|
|
])
|
|
->recordActions([
|
|
Action::make('test')
|
|
->label(__('Test'))
|
|
->icon('heroicon-o-check-circle')
|
|
->color('success')
|
|
->size('sm')
|
|
->action(fn (BackupDestination $record) => $this->testDestination($record->id)),
|
|
Action::make('delete')
|
|
->label(__('Delete'))
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->size('sm')
|
|
->requiresConfirmation()
|
|
->action(fn (BackupDestination $record) => $this->deleteDestination($record->id)),
|
|
])
|
|
->emptyStateHeading(__('No remote destinations configured'))
|
|
->emptyStateDescription(__('Click "Add Destination" to configure SFTP, NFS, or S3 storage'))
|
|
->emptyStateIcon('heroicon-o-server-stack')
|
|
->striped();
|
|
}
|
|
|
|
protected function schedulesTable(Table $table): Table
|
|
{
|
|
return $table
|
|
->query(BackupSchedule::query()->where('is_server_backup', true)->with('destination')->orderBy('name'))
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label(__('Name'))
|
|
->weight('medium')
|
|
->searchable(),
|
|
TextColumn::make('frequency_label')
|
|
->label(__('Frequency')),
|
|
TextColumn::make('destination.name')
|
|
->label(__('Destination'))
|
|
->placeholder(__('Local')),
|
|
TextColumn::make('retention_count')
|
|
->label(__('Retention'))
|
|
->formatStateUsing(fn (int $state): string => $state.' '.__('backups')),
|
|
TextColumn::make('last_run_at')
|
|
->label(__('Last Run'))
|
|
->since()
|
|
->dateTimeTooltip('M j, Y H:i T', timezone: $this->getSystemTimezone())
|
|
->placeholder(__('Never'))
|
|
->color('gray'),
|
|
TextColumn::make('next_run_at')
|
|
->label(__('Next Run'))
|
|
->since()
|
|
->dateTimeTooltip('M j, Y H:i T', timezone: $this->getSystemTimezone())
|
|
->placeholder(__('Not scheduled'))
|
|
->color('gray'),
|
|
ViewColumn::make('status')
|
|
->label(__('Status'))
|
|
->view('filament.admin.columns.schedule-status'),
|
|
])
|
|
->recordActions([
|
|
Action::make('run')
|
|
->label(__('Run'))
|
|
->icon('heroicon-o-play')
|
|
->color('gray')
|
|
->size('sm')
|
|
->visible(fn (BackupSchedule $record): bool => ! Backup::where('schedule_id', $record->id)->running()->exists())
|
|
->action(fn (BackupSchedule $record) => $this->runScheduleNow($record->id)),
|
|
Action::make('stop')
|
|
->label(__('Stop'))
|
|
->icon('heroicon-o-stop')
|
|
->color('danger')
|
|
->size('sm')
|
|
->visible(fn (BackupSchedule $record): bool => Backup::where('schedule_id', $record->id)->running()->exists())
|
|
->requiresConfirmation()
|
|
->action(fn (BackupSchedule $record) => $this->stopScheduleBackup($record->id)),
|
|
Action::make('edit')
|
|
->label(__('Edit'))
|
|
->icon('heroicon-o-pencil')
|
|
->color('gray')
|
|
->size('sm')
|
|
->action(fn (BackupSchedule $record) => $this->mountAction('editSchedule', ['id' => $record->id])),
|
|
Action::make('toggle')
|
|
->label(fn (BackupSchedule $record): string => $record->is_active ? __('Disable') : __('Enable'))
|
|
->icon(fn (BackupSchedule $record): string => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play')
|
|
->color('gray')
|
|
->size('sm')
|
|
->action(fn (BackupSchedule $record) => $this->toggleSchedule($record->id)),
|
|
Action::make('delete')
|
|
->label(__('Delete'))
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->size('sm')
|
|
->requiresConfirmation()
|
|
->action(fn (BackupSchedule $record) => $this->deleteSchedule($record->id)),
|
|
])
|
|
->headerActions([
|
|
$this->addScheduleAction(),
|
|
])
|
|
->emptyStateHeading(__('No backup schedules configured'))
|
|
->emptyStateDescription(__('Click "Add Schedule" to set up automatic backups'))
|
|
->emptyStateIcon('heroicon-o-clock')
|
|
->striped()
|
|
->poll(fn () => Backup::running()->exists() ? '3s' : null);
|
|
}
|
|
|
|
protected function backupsTable(Table $table): Table
|
|
{
|
|
return $table
|
|
->query(Backup::query()->where('type', 'server')->with(['destination', 'user'])->orderByDesc('created_at')->limit(50))
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->label(__('Name'))
|
|
->weight('medium')
|
|
->searchable()
|
|
->limit(40),
|
|
ViewColumn::make('status')
|
|
->label(__('Status'))
|
|
->view('filament.admin.columns.backup-status'),
|
|
TextColumn::make('size_bytes')
|
|
->label(__('Size'))
|
|
->formatStateUsing(fn (Backup $record): string => $record->size_human),
|
|
TextColumn::make('destination.name')
|
|
->label(__('Destination'))
|
|
->placeholder(__('Local')),
|
|
TextColumn::make('created_at')
|
|
->label(__('Created'))
|
|
->dateTime('M j, Y H:i')
|
|
->color('gray'),
|
|
TextColumn::make('duration')
|
|
->label(__('Duration'))
|
|
->placeholder(__('-'))
|
|
->color('gray'),
|
|
])
|
|
->recordActions([
|
|
Action::make('restore')
|
|
->label(__('Restore'))
|
|
->icon('heroicon-o-arrow-path')
|
|
->color('warning')
|
|
->size('sm')
|
|
->visible(fn (Backup $record): bool => $record->status === 'completed' && ($record->local_path || $record->remote_path))
|
|
->modalHeading(__('Restore Backup'))
|
|
->modalDescription(__('Select what you want to restore. Warning: Existing data may be overwritten.'))
|
|
->modalWidth('xl')
|
|
->form(function (Backup $record): array {
|
|
// Check if this is a remote backup (no local files)
|
|
$isRemoteBackup = ! $record->local_path || ! file_exists($record->local_path);
|
|
|
|
$manifest = $this->getBackupManifest($record);
|
|
$users = $manifest['users'] ?? $record->users ?? [];
|
|
if (empty($users)) {
|
|
$users = [$manifest['username'] ?? ''];
|
|
}
|
|
$users = array_filter($users);
|
|
|
|
$isServerBackup = ($manifest['type'] ?? $record->type) === 'server' && count($users) > 1;
|
|
|
|
// For server backups, get data for first user by default
|
|
$selectedUser = $users[0] ?? '';
|
|
if ($isServerBackup && ! empty($selectedUser) && ! $isRemoteBackup) {
|
|
$manifest = $this->getBackupManifest($record, $selectedUser);
|
|
}
|
|
|
|
// For remote backups, use include_* flags from record
|
|
if ($isRemoteBackup) {
|
|
$hasFiles = $record->include_files ?? true;
|
|
$hasDatabases = $record->include_databases ?? true;
|
|
$hasMailboxes = $record->include_mailboxes ?? true;
|
|
$hasDns = $record->include_dns ?? true;
|
|
$hasSsl = true; // Assume SSL is included
|
|
$domains = [];
|
|
$databases = [];
|
|
$mailboxes = [];
|
|
} else {
|
|
$domains = $manifest['domains'] ?? [];
|
|
$databases = $manifest['databases'] ?? [];
|
|
$mailboxes = $manifest['mailboxes'] ?? [];
|
|
$hasFiles = ! empty($domains);
|
|
$hasDatabases = ! empty($databases);
|
|
$hasMailboxes = ! empty($mailboxes);
|
|
$hasDns = ! empty($manifest['dns_zones'] ?? []);
|
|
$hasSsl = ! empty($manifest['ssl_certificates'] ?? []);
|
|
}
|
|
|
|
$schema = [];
|
|
|
|
// Backup info section
|
|
$infoSchema = [
|
|
TextInput::make('backup_name')
|
|
->label(__('Backup'))
|
|
->default($record->name)
|
|
->disabled(),
|
|
];
|
|
|
|
// Add user selector for server backups with multiple users
|
|
if ($isServerBackup || count($users) > 1) {
|
|
$userOptions = [
|
|
'__all__' => __('All users'),
|
|
];
|
|
foreach ($users as $userOption) {
|
|
$userOptions[$userOption] = $userOption;
|
|
}
|
|
$infoSchema[] = Select::make('restore_username')
|
|
->label(__('User to Restore'))
|
|
->options($userOptions)
|
|
->default($selectedUser)
|
|
->required()
|
|
->helperText(__('Backup contains :count user(s)', ['count' => count($users)]));
|
|
} else {
|
|
$infoSchema[] = TextInput::make('restore_username')
|
|
->label(__('User'))
|
|
->default($selectedUser)
|
|
->disabled();
|
|
}
|
|
|
|
$schema[] = Section::make(__('Backup Information'))
|
|
->schema([Grid::make(2)->schema($infoSchema)]);
|
|
|
|
// Remote backup notice
|
|
if ($isRemoteBackup) {
|
|
$schema[] = Section::make(__('Remote Backup'))
|
|
->description(__('This backup will be downloaded from the remote destination before restoring.'))
|
|
->icon('heroicon-o-cloud-arrow-down')
|
|
->iconColor('info');
|
|
}
|
|
|
|
// Restore options section
|
|
$restoreOptions = [];
|
|
|
|
// Website Files
|
|
$filesLabel = __('Website Files');
|
|
if (! $isRemoteBackup && ! empty($domains)) {
|
|
$filesLabel .= ' ('.count($domains).')';
|
|
}
|
|
$restoreOptions[] = Toggle::make('restore_files')
|
|
->label($filesLabel)
|
|
->helperText($isRemoteBackup
|
|
? __('Restore all domain files')
|
|
: (! empty($domains) ? implode(', ', array_slice($domains, 0, 3)).(count($domains) > 3 ? '...' : '') : __('No files')))
|
|
->default($hasFiles)
|
|
->disabled(! $hasFiles && ! $isRemoteBackup);
|
|
|
|
if (! $isRemoteBackup && count($domains) > 1) {
|
|
$restoreOptions[] = Select::make('selected_domains')
|
|
->label(__('Specific Domains'))
|
|
->multiple()
|
|
->options(fn () => array_combine($domains, $domains))
|
|
->placeholder(__('All domains'))
|
|
->visible(fn ($get) => $get('restore_files'));
|
|
}
|
|
|
|
// Databases
|
|
$dbLabel = __('Databases');
|
|
if (! $isRemoteBackup && ! empty($databases)) {
|
|
$dbLabel .= ' ('.count($databases).')';
|
|
}
|
|
$restoreOptions[] = Toggle::make('restore_databases')
|
|
->label($dbLabel)
|
|
->helperText($isRemoteBackup
|
|
? __('Restore all databases')
|
|
: (! empty($databases) ? implode(', ', array_slice($databases, 0, 3)).(count($databases) > 3 ? '...' : '') : __('No databases')))
|
|
->default($hasDatabases)
|
|
->disabled(! $hasDatabases && ! $isRemoteBackup);
|
|
|
|
if (! $isRemoteBackup && count($databases) > 1) {
|
|
$restoreOptions[] = Select::make('selected_databases')
|
|
->label(__('Specific Databases'))
|
|
->multiple()
|
|
->options(fn () => array_combine($databases, $databases))
|
|
->placeholder(__('All databases'))
|
|
->visible(fn ($get) => $get('restore_databases'));
|
|
}
|
|
|
|
// MySQL Users
|
|
$restoreOptions[] = Toggle::make('restore_mysql_users')
|
|
->label(__('MySQL Users'))
|
|
->default($hasDatabases)
|
|
->helperText(__('Restore MySQL users and their permissions'));
|
|
|
|
// Mailboxes
|
|
$mailLabel = __('Mailboxes');
|
|
if (! $isRemoteBackup && ! empty($mailboxes)) {
|
|
$mailLabel .= ' ('.count($mailboxes).')';
|
|
}
|
|
$restoreOptions[] = Toggle::make('restore_mailboxes')
|
|
->label($mailLabel)
|
|
->helperText($isRemoteBackup
|
|
? __('Restore all mailboxes')
|
|
: (! empty($mailboxes) ? implode(', ', array_slice($mailboxes, 0, 3)).(count($mailboxes) > 3 ? '...' : '') : __('No mailboxes')))
|
|
->default($hasMailboxes)
|
|
->disabled(! $hasMailboxes && ! $isRemoteBackup);
|
|
|
|
// SSL Certificates
|
|
$restoreOptions[] = Toggle::make('restore_ssl')
|
|
->label(__('SSL Certificates'))
|
|
->default(false)
|
|
->helperText(__('Restore SSL certificates for domains'));
|
|
|
|
// DNS Zones
|
|
$restoreOptions[] = Toggle::make('restore_dns')
|
|
->label(__('DNS Zones'))
|
|
->default($hasDns)
|
|
->helperText(__('Restore DNS zone files'));
|
|
|
|
$schema[] = Section::make(__('Restore Options'))
|
|
->description(__('Toggle items you want to restore.'))
|
|
->schema($restoreOptions);
|
|
|
|
return $schema;
|
|
})
|
|
->action(function (array $data, Backup $record): void {
|
|
$this->executeRestore($record, $data);
|
|
})
|
|
->modalSubmitActionLabel(__('Restore'))
|
|
->requiresConfirmation(),
|
|
Action::make('download')
|
|
->label(__('Download'))
|
|
->icon('heroicon-o-arrow-down-tray')
|
|
->color('gray')
|
|
->size('sm')
|
|
->visible(fn (Backup $record): bool => $record->canDownload())
|
|
->url(fn (Backup $record): string => route('filament.admin.pages.backup-download', ['id' => $record->id]))
|
|
->openUrlInNewTab(),
|
|
Action::make('delete')
|
|
->label(__('Delete'))
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->size('sm')
|
|
->requiresConfirmation()
|
|
->action(fn (Backup $record) => $this->deleteBackup($record->id)),
|
|
])
|
|
->emptyStateHeading(__('No server backups yet'))
|
|
->emptyStateDescription(__('Click "Create Server Backup" to create your first backup'))
|
|
->emptyStateIcon('heroicon-o-archive-box')
|
|
->striped()
|
|
->poll(fn () => Backup::whereIn('status', ['pending', 'running', 'uploading'])->exists() ? '3s' : null);
|
|
}
|
|
|
|
public function getTableRecordKey(Model|array $record): string
|
|
{
|
|
return is_array($record) ? (string) $record['id'] : (string) $record->getKey();
|
|
}
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Action::make('createServerBackup')
|
|
->label(__('Create Server Backup'))
|
|
->icon('heroicon-o-archive-box-arrow-down')
|
|
->color('primary')
|
|
->form([
|
|
TextInput::make('name')
|
|
->label(__('Backup Name'))
|
|
->default(fn () => __('Server Backup').' '.now()->format('Y-m-d H:i'))
|
|
->required(),
|
|
Select::make('destination_id')
|
|
->label(__('Destination'))
|
|
->options(fn () => BackupDestination::where('is_server_backup', true)
|
|
->where('is_active', true)
|
|
->pluck('name', 'id')
|
|
->prepend(__('Local Storage'), ''))
|
|
->default('')
|
|
->live()
|
|
->afterStateUpdated(fn ($set, $state) => $set('backup_type', $this->supportsIncremental($state) ? 'incremental' : 'full')),
|
|
Radio::make('backup_type')
|
|
->label(__('Backup Type'))
|
|
->options(fn ($get) => $this->supportsIncremental($get('destination_id'))
|
|
? [
|
|
'incremental' => __('Incremental (rsync) - Space-efficient'),
|
|
'full' => __('Full (tar.gz) - Complete archive'),
|
|
]
|
|
: [
|
|
'full' => __('Full (tar.gz) - Complete archive'),
|
|
])
|
|
->default('full')
|
|
->required(),
|
|
TextInput::make('local_path')
|
|
->label(__('Local Backup Folder'))
|
|
->default('/var/backups/jabali')
|
|
->visible(fn ($get) => empty($get('destination_id'))),
|
|
Section::make(__('Include'))
|
|
->schema([
|
|
Grid::make(2)->schema([
|
|
Toggle::make('include_files')->label(__('Website Files'))->default(true),
|
|
Toggle::make('include_databases')->label(__('Databases'))->default(true),
|
|
Toggle::make('include_mailboxes')->label(__('Mailboxes'))->default(true),
|
|
Toggle::make('include_dns')->label(__('DNS Records'))->default(true),
|
|
]),
|
|
]),
|
|
Select::make('users')
|
|
->label(__('Users to Backup'))
|
|
->multiple()
|
|
->options(fn () => User::where('is_admin', false)
|
|
->where('is_active', true)
|
|
->pluck('username', 'username'))
|
|
->placeholder(__('All Users')),
|
|
])
|
|
->action(function (array $data) {
|
|
$this->createServerBackup($data);
|
|
}),
|
|
|
|
$this->createUserBackupAction(),
|
|
|
|
Action::make('addDestination')
|
|
->label(__('Add Destination'))
|
|
->icon('heroicon-o-plus')
|
|
->color('gray')
|
|
->form($this->getDestinationForm())
|
|
->action(function (array $data) {
|
|
$this->saveDestination($data);
|
|
}),
|
|
];
|
|
}
|
|
|
|
protected function getDestinationForm(): array
|
|
{
|
|
return [
|
|
TextInput::make('name')
|
|
->label(__('Destination Name'))
|
|
->required(),
|
|
Select::make('type')
|
|
->label(__('Type'))
|
|
->options([
|
|
'sftp' => __('SFTP Server'),
|
|
'nfs' => __('NFS Mount'),
|
|
's3' => __('S3-Compatible Storage'),
|
|
])
|
|
->required()
|
|
->live(),
|
|
|
|
Section::make(__('SFTP Settings'))
|
|
->visible(fn ($get) => $get('type') === 'sftp')
|
|
->schema([
|
|
Grid::make(2)->schema([
|
|
TextInput::make('host')->label(__('Host'))->required(),
|
|
TextInput::make('port')->label(__('Port'))->numeric()->default(22),
|
|
]),
|
|
TextInput::make('username')->label(__('Username'))->required(),
|
|
TextInput::make('password')->label(__('Password'))->password(),
|
|
Textarea::make('private_key')->label(__('Private Key (SSH)'))->rows(4),
|
|
TextInput::make('path')->label(__('Remote Path'))->default('/backups'),
|
|
]),
|
|
|
|
Section::make(__('NFS Settings'))
|
|
->visible(fn ($get) => $get('type') === 'nfs')
|
|
->schema([
|
|
TextInput::make('server')->label(__('NFS Server'))->required(),
|
|
TextInput::make('share')->label(__('Share Path'))->required(),
|
|
TextInput::make('path')->label(__('Sub-directory'))->default(''),
|
|
]),
|
|
|
|
Section::make(__('S3-Compatible Settings'))
|
|
->visible(fn ($get) => $get('type') === 's3')
|
|
->schema([
|
|
TextInput::make('endpoint')->label(__('Endpoint URL')),
|
|
TextInput::make('bucket')->label(__('Bucket Name'))->required(),
|
|
Grid::make(2)->schema([
|
|
TextInput::make('access_key')->label(__('Access Key ID'))->required(),
|
|
TextInput::make('secret_key')->label(__('Secret Access Key'))->password()->required(),
|
|
]),
|
|
TextInput::make('region')->label(__('Region'))->default('us-east-1'),
|
|
TextInput::make('path')->label(__('Path Prefix'))->default('backups'),
|
|
]),
|
|
|
|
Toggle::make('is_default')->label(__('Set as Default Destination')),
|
|
|
|
FormActions::make([
|
|
Action::make('testConnection')
|
|
->label(__('Test Connection'))
|
|
->icon('heroicon-o-signal')
|
|
->color('gray')
|
|
->action(function ($get, $livewire) {
|
|
$type = $get('type');
|
|
if (empty($type)) {
|
|
Notification::make()
|
|
->title(__('Select a destination type first'))
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$config = match ($type) {
|
|
'sftp' => [
|
|
'type' => 'sftp',
|
|
'host' => $get('host') ?? '',
|
|
'port' => (int) ($get('port') ?? 22),
|
|
'username' => $get('username') ?? '',
|
|
'password' => $get('password') ?? '',
|
|
'private_key' => $get('private_key') ?? '',
|
|
'path' => $get('path') ?? '/backups',
|
|
],
|
|
'nfs' => [
|
|
'type' => 'nfs',
|
|
'server' => $get('server') ?? '',
|
|
'share' => $get('share') ?? '',
|
|
'path' => $get('path') ?? '',
|
|
],
|
|
's3' => [
|
|
'type' => 's3',
|
|
'endpoint' => $get('endpoint') ?? '',
|
|
'bucket' => $get('bucket') ?? '',
|
|
'access_key' => $get('access_key') ?? '',
|
|
'secret_key' => $get('secret_key') ?? '',
|
|
'region' => $get('region') ?? 'us-east-1',
|
|
'path' => $get('path') ?? 'backups',
|
|
],
|
|
default => [],
|
|
};
|
|
|
|
if (empty($config)) {
|
|
Notification::make()
|
|
->title(__('Invalid destination type'))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$result = $livewire->getAgent()->backupTestDestination($config);
|
|
if ($result['success']) {
|
|
Notification::make()
|
|
->title(__('Connection successful'))
|
|
->body(__('The destination is reachable and ready to use.'))
|
|
->success()
|
|
->send();
|
|
} else {
|
|
Notification::make()
|
|
->title(__('Connection failed'))
|
|
->body($result['error'] ?? __('Could not connect to destination'))
|
|
->danger()
|
|
->send();
|
|
}
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('Connection test failed'))
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
}
|
|
}),
|
|
])->visible(fn ($get) => ! empty($get('type'))),
|
|
];
|
|
}
|
|
|
|
public function saveDestination(array $data): void
|
|
{
|
|
$config = [];
|
|
$type = $data['type'];
|
|
|
|
switch ($type) {
|
|
case 'sftp':
|
|
$config = [
|
|
'host' => $data['host'] ?? '',
|
|
'port' => (int) ($data['port'] ?? 22),
|
|
'username' => $data['username'] ?? '',
|
|
'password' => $data['password'] ?? '',
|
|
'private_key' => $data['private_key'] ?? '',
|
|
'path' => $data['path'] ?? '/backups',
|
|
];
|
|
break;
|
|
|
|
case 'nfs':
|
|
$config = [
|
|
'server' => $data['server'] ?? '',
|
|
'share' => $data['share'] ?? '',
|
|
'path' => $data['path'] ?? '',
|
|
];
|
|
break;
|
|
|
|
case 's3':
|
|
$config = [
|
|
'endpoint' => $data['endpoint'] ?? '',
|
|
'bucket' => $data['bucket'] ?? '',
|
|
'access_key' => $data['access_key'] ?? '',
|
|
'secret_key' => $data['secret_key'] ?? '',
|
|
'region' => $data['region'] ?? 'us-east-1',
|
|
'path' => $data['path'] ?? 'backups',
|
|
];
|
|
break;
|
|
}
|
|
|
|
$testConfig = array_merge($config, ['type' => $type]);
|
|
try {
|
|
$result = $this->getAgent()->backupTestDestination($testConfig);
|
|
if (! $result['success']) {
|
|
Notification::make()
|
|
->title(__('Connection failed'))
|
|
->body($result['error'] ?? __('Could not connect to destination'))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
} catch (Exception $e) {
|
|
Notification::make()
|
|
->title(__('Connection test failed'))
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
BackupDestination::create([
|
|
'name' => $data['name'],
|
|
'type' => $type,
|
|
'config' => $config,
|
|
'is_server_backup' => true,
|
|
'is_default' => $data['is_default'] ?? false,
|
|
'is_active' => true,
|
|
'last_tested_at' => now(),
|
|
'test_status' => 'success',
|
|
]);
|
|
|
|
Notification::make()->title(__('Destination verified and added'))->success()->send();
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function testDestination(int $id): void
|
|
{
|
|
$destination = BackupDestination::find($id);
|
|
if (! $destination) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$config = array_merge($destination->config ?? [], ['type' => $destination->type]);
|
|
$result = $this->getAgent()->backupTestDestination($config);
|
|
|
|
$destination->update([
|
|
'last_tested_at' => now(),
|
|
'test_status' => $result['success'] ? 'success' : 'failed',
|
|
'test_message' => $result['message'] ?? $result['error'] ?? null,
|
|
]);
|
|
|
|
if ($result['success']) {
|
|
Notification::make()->title(__('Connection successful'))->success()->send();
|
|
} else {
|
|
Notification::make()->title(__('Connection failed'))->body($result['error'] ?? __('Unknown error'))->danger()->send();
|
|
}
|
|
} catch (Exception $e) {
|
|
$destination->update([
|
|
'last_tested_at' => now(),
|
|
'test_status' => 'failed',
|
|
'test_message' => $e->getMessage(),
|
|
]);
|
|
Notification::make()->title(__('Test failed'))->body($e->getMessage())->danger()->send();
|
|
}
|
|
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function deleteDestination(int $id): void
|
|
{
|
|
BackupDestination::where('id', $id)->delete();
|
|
Notification::make()->title(__('Destination deleted'))->success()->send();
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function createServerBackup(array $data): void
|
|
{
|
|
$backupType = $data['backup_type'] ?? 'full';
|
|
$timestamp = now()->format('Y-m-d_His');
|
|
$folderName = $timestamp;
|
|
$baseFolder = rtrim($data['local_path'] ?? '/var/backups/jabali', '/');
|
|
$outputPath = "{$baseFolder}/{$folderName}";
|
|
|
|
$isIncrementalRemote = $backupType === 'incremental' && ! empty($data['destination_id']);
|
|
|
|
if ($isIncrementalRemote) {
|
|
$destination = BackupDestination::find($data['destination_id']);
|
|
if (! $destination || ! in_array($destination->type, ['sftp', 'nfs'])) {
|
|
Notification::make()
|
|
->title(__('Invalid destination'))
|
|
->body(__('Incremental backups require an SFTP or NFS destination'))
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create backup record with pending status
|
|
$backup = Backup::create([
|
|
'name' => $data['name'],
|
|
'filename' => $folderName,
|
|
'type' => 'server',
|
|
'include_files' => $data['include_files'] ?? true,
|
|
'include_databases' => $data['include_databases'] ?? true,
|
|
'include_mailboxes' => $data['include_mailboxes'] ?? true,
|
|
'include_dns' => $data['include_dns'] ?? true,
|
|
'users' => ! empty($data['users']) ? $data['users'] : null,
|
|
'destination_id' => ! empty($data['destination_id']) ? $data['destination_id'] : null,
|
|
'schedule_id' => $data['schedule_id'] ?? null,
|
|
'status' => 'pending',
|
|
'local_path' => $isIncrementalRemote ? null : $outputPath,
|
|
'metadata' => ['backup_type' => $backupType],
|
|
]);
|
|
|
|
// Dispatch job to run backup in background
|
|
\App\Jobs\RunServerBackup::dispatch($backup->id);
|
|
|
|
// Show notification and refresh table
|
|
Notification::make()
|
|
->title(__('Backup started'))
|
|
->body(__('The backup is running in the background. The status will update automatically.'))
|
|
->info()
|
|
->send();
|
|
|
|
$this->resetTable();
|
|
}
|
|
|
|
protected function uploadToRemote(Backup $backup, bool $keepLocal = false): bool
|
|
{
|
|
if (! $backup->destination || ! $backup->local_path) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$backup->update(['status' => 'uploading']);
|
|
|
|
$config = array_merge($backup->destination->config ?? [], ['type' => $backup->destination->type]);
|
|
$backupType = $backup->metadata['backup_type'] ?? 'full';
|
|
$result = $this->getAgent()->backupUploadRemote($backup->local_path, $config, $backupType);
|
|
|
|
if ($result['success']) {
|
|
$backup->update([
|
|
'status' => 'completed',
|
|
'remote_path' => $result['remote_path'] ?? null,
|
|
]);
|
|
|
|
if (! $keepLocal && $backup->local_path) {
|
|
$this->getAgent()->backupDeleteServer($backup->local_path);
|
|
$backup->update(['local_path' => null]);
|
|
}
|
|
|
|
return true;
|
|
} else {
|
|
throw new Exception($result['error'] ?? __('Upload failed'));
|
|
}
|
|
} catch (Exception $e) {
|
|
$backup->update([
|
|
'status' => 'completed',
|
|
'error_message' => __('Remote upload failed').': '.$e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function deleteBackup(int $id): void
|
|
{
|
|
$backup = Backup::find($id);
|
|
if (! $backup) {
|
|
return;
|
|
}
|
|
|
|
// Delete local file/folder
|
|
if ($backup->local_path && file_exists($backup->local_path)) {
|
|
if (is_file($backup->local_path)) {
|
|
unlink($backup->local_path);
|
|
} else {
|
|
exec('rm -rf '.escapeshellarg($backup->local_path));
|
|
}
|
|
}
|
|
|
|
// Delete from remote destination if exists
|
|
if ($backup->remote_path && $backup->destination) {
|
|
try {
|
|
$config = array_merge(
|
|
$backup->destination->config ?? [],
|
|
['type' => $backup->destination->type]
|
|
);
|
|
$this->getAgent()->send('backup.delete_remote', [
|
|
'remote_path' => $backup->remote_path,
|
|
'destination' => $config,
|
|
]);
|
|
} catch (Exception $e) {
|
|
// Log but continue - we still want to delete the DB record
|
|
logger()->warning('Failed to delete remote backup: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
$backup->delete();
|
|
Notification::make()->title(__('Backup deleted'))->success()->send();
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function addScheduleAction(): Action
|
|
{
|
|
return Action::make('addSchedule')
|
|
->label(__('Add Schedule'))
|
|
->icon('heroicon-o-clock')
|
|
->color('primary')
|
|
->form([
|
|
TextInput::make('name')
|
|
->label(__('Schedule Name'))
|
|
->required(),
|
|
Select::make('destination_id')
|
|
->label(__('Destination'))
|
|
->options(fn () => BackupDestination::where('is_server_backup', true)
|
|
->where('is_active', true)
|
|
->pluck('name', 'id')
|
|
->prepend(__('Local Storage'), ''))
|
|
->default('')
|
|
->live()
|
|
->afterStateUpdated(fn ($set, $state) => $set('backup_type', $this->supportsIncremental($state) ? 'incremental' : 'full')),
|
|
Radio::make('backup_type')
|
|
->label(__('Backup Type'))
|
|
->options(fn ($get) => $this->supportsIncremental($get('destination_id'))
|
|
? [
|
|
'incremental' => __('Incremental (rsync)'),
|
|
'full' => __('Full (tar.gz)'),
|
|
]
|
|
: [
|
|
'full' => __('Full (tar.gz)'),
|
|
])
|
|
->default('full')
|
|
->required(),
|
|
Select::make('frequency')
|
|
->label(__('Frequency'))
|
|
->options([
|
|
'hourly' => __('Hourly'),
|
|
'daily' => __('Daily'),
|
|
'weekly' => __('Weekly'),
|
|
'monthly' => __('Monthly'),
|
|
])
|
|
->required()
|
|
->live(),
|
|
TextInput::make('time')
|
|
->label(__('Time (HH:MM)'))
|
|
->default('02:00')
|
|
->visible(fn ($get) => in_array($get('frequency'), ['daily', 'weekly', 'monthly'])),
|
|
Select::make('day_of_week')
|
|
->label(__('Day of Week'))
|
|
->options([
|
|
0 => __('Sunday'), 1 => __('Monday'), 2 => __('Tuesday'),
|
|
3 => __('Wednesday'), 4 => __('Thursday'), 5 => __('Friday'), 6 => __('Saturday'),
|
|
])
|
|
->visible(fn ($get) => $get('frequency') === 'weekly'),
|
|
Select::make('day_of_month')
|
|
->label(__('Day of Month'))
|
|
->options(array_combine(range(1, 28), range(1, 28)))
|
|
->visible(fn ($get) => $get('frequency') === 'monthly'),
|
|
TextInput::make('retention_count')
|
|
->label(__('Keep Last N Backups'))
|
|
->numeric()
|
|
->default(7),
|
|
Section::make(__('Include'))
|
|
->schema([
|
|
Grid::make(2)->schema([
|
|
Toggle::make('include_files')->label(__('Website Files'))->default(true),
|
|
Toggle::make('include_databases')->label(__('Databases'))->default(true),
|
|
Toggle::make('include_mailboxes')->label(__('Mailboxes'))->default(true),
|
|
Toggle::make('include_dns')->label(__('DNS Records'))->default(true),
|
|
]),
|
|
]),
|
|
])
|
|
->action(function (array $data) {
|
|
$schedule = BackupSchedule::create([
|
|
'name' => $data['name'],
|
|
'is_server_backup' => true,
|
|
'is_active' => true,
|
|
'frequency' => $data['frequency'],
|
|
'time' => $data['time'] ?? '02:00',
|
|
'day_of_week' => $data['day_of_week'] ?? null,
|
|
'day_of_month' => $data['day_of_month'] ?? null,
|
|
'destination_id' => ! empty($data['destination_id']) ? $data['destination_id'] : null,
|
|
'retention_count' => $data['retention_count'] ?? 7,
|
|
'include_files' => $data['include_files'] ?? true,
|
|
'include_databases' => $data['include_databases'] ?? true,
|
|
'include_mailboxes' => $data['include_mailboxes'] ?? true,
|
|
'include_dns' => $data['include_dns'] ?? true,
|
|
'metadata' => ['backup_type' => $data['backup_type'] ?? 'full'],
|
|
]);
|
|
|
|
$schedule->calculateNextRun();
|
|
$schedule->save();
|
|
|
|
Notification::make()->title(__('Schedule created'))->success()->send();
|
|
$this->resetTable();
|
|
});
|
|
}
|
|
|
|
public function toggleSchedule(int $id): void
|
|
{
|
|
$schedule = BackupSchedule::find($id);
|
|
if (! $schedule) {
|
|
return;
|
|
}
|
|
|
|
$schedule->update(['is_active' => ! $schedule->is_active]);
|
|
|
|
if ($schedule->is_active) {
|
|
$schedule->calculateNextRun();
|
|
$schedule->save();
|
|
}
|
|
|
|
Notification::make()->title($schedule->is_active ? __('Schedule enabled') : __('Schedule disabled'))->success()->send();
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function deleteSchedule(int $id): void
|
|
{
|
|
BackupSchedule::where('id', $id)->delete();
|
|
Notification::make()->title(__('Schedule deleted'))->success()->send();
|
|
$this->resetTable();
|
|
}
|
|
|
|
public function editScheduleAction(): Action
|
|
{
|
|
return Action::make('editSchedule')
|
|
->label(__('Edit Schedule'))
|
|
->icon('heroicon-o-pencil')
|
|
->color('gray')
|
|
->fillForm(function (array $arguments): array {
|
|
$schedule = BackupSchedule::find($arguments['id']);
|
|
if (! $schedule) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'name' => $schedule->name,
|
|
'backup_type' => $schedule->metadata['backup_type'] ?? 'full',
|
|
'frequency' => $schedule->frequency,
|
|
'time' => $schedule->time,
|
|
'day_of_week' => $schedule->day_of_week,
|
|
'day_of_month' => $schedule->day_of_month,
|
|
'destination_id' => $schedule->destination_id ?? '',
|
|
'retention_count' => $schedule->retention_count,
|
|
'include_files' => $schedule->include_files,
|
|
'include_databases' => $schedule->include_databases,
|
|
'include_mailboxes' => $schedule->include_mailboxes,
|
|
'include_dns' => $schedule->include_dns,
|
|
];
|
|
})
|
|
->form([
|
|
TextInput::make('name')->label(__('Schedule Name'))->required(),
|
|
Select::make('destination_id')
|
|
->label(__('Destination'))
|
|
->options(fn () => BackupDestination::where('is_server_backup', true)
|
|
->where('is_active', true)
|
|
->pluck('name', 'id')
|
|
->prepend(__('Local Storage'), ''))
|
|
->default('')
|
|
->live(),
|
|
Radio::make('backup_type')
|
|
->label(__('Backup Type'))
|
|
->options(fn ($get) => $this->supportsIncremental($get('destination_id'))
|
|
? ['incremental' => __('Incremental'), 'full' => __('Full')]
|
|
: ['full' => __('Full')])
|
|
->required(),
|
|
Select::make('frequency')
|
|
->label(__('Frequency'))
|
|
->options(['hourly' => __('Hourly'), 'daily' => __('Daily'), 'weekly' => __('Weekly'), 'monthly' => __('Monthly')])
|
|
->required()
|
|
->live(),
|
|
TextInput::make('time')->label(__('Time (HH:MM)'))->visible(fn ($get) => in_array($get('frequency'), ['daily', 'weekly', 'monthly'])),
|
|
Select::make('day_of_week')
|
|
->label(__('Day of Week'))
|
|
->options([0 => __('Sunday'), 1 => __('Monday'), 2 => __('Tuesday'), 3 => __('Wednesday'), 4 => __('Thursday'), 5 => __('Friday'), 6 => __('Saturday')])
|
|
->visible(fn ($get) => $get('frequency') === 'weekly'),
|
|
Select::make('day_of_month')
|
|
->label(__('Day of Month'))
|
|
->options(array_combine(range(1, 28), range(1, 28)))
|
|
->visible(fn ($get) => $get('frequency') === 'monthly'),
|
|
TextInput::make('retention_count')->label(__('Keep Last N Backups'))->numeric(),
|
|
Section::make(__('Include'))
|
|
->schema([
|
|
Grid::make(2)->schema([
|
|
Toggle::make('include_files')->label(__('Website Files')),
|
|
Toggle::make('include_databases')->label(__('Databases')),
|
|
Toggle::make('include_mailboxes')->label(__('Mailboxes')),
|
|
Toggle::make('include_dns')->label(__('DNS Records')),
|
|
]),
|
|
]),
|
|
])
|
|
->action(function (array $data, array $arguments) {
|
|
$schedule = BackupSchedule::find($arguments['id']);
|
|
if (! $schedule) {
|
|
return;
|
|
}
|
|
|
|
$schedule->update([
|
|
'name' => $data['name'],
|
|
'frequency' => $data['frequency'],
|
|
'time' => $data['time'] ?? '02:00',
|
|
'day_of_week' => $data['day_of_week'] ?? null,
|
|
'day_of_month' => $data['day_of_month'] ?? null,
|
|
'destination_id' => ! empty($data['destination_id']) ? $data['destination_id'] : null,
|
|
'retention_count' => $data['retention_count'] ?? 7,
|
|
'include_files' => $data['include_files'] ?? true,
|
|
'include_databases' => $data['include_databases'] ?? true,
|
|
'include_mailboxes' => $data['include_mailboxes'] ?? true,
|
|
'include_dns' => $data['include_dns'] ?? true,
|
|
'metadata' => array_merge($schedule->metadata ?? [], ['backup_type' => $data['backup_type'] ?? 'full']),
|
|
]);
|
|
|
|
$schedule->calculateNextRun();
|
|
$schedule->save();
|
|
|
|
Notification::make()->title(__('Schedule updated'))->success()->send();
|
|
$this->resetTable();
|
|
});
|
|
}
|
|
|
|
public function runScheduleNow(int $id): void
|
|
{
|
|
$schedule = BackupSchedule::find($id);
|
|
if (! $schedule) {
|
|
return;
|
|
}
|
|
|
|
$runningBackup = Backup::where('schedule_id', $id)->running()->first();
|
|
if ($runningBackup) {
|
|
Notification::make()->title(__('Backup already running'))->warning()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->createServerBackup([
|
|
'name' => $schedule->name.' - '.__('Manual Run').' '.now()->format('Y-m-d H:i'),
|
|
'backup_type' => $schedule->metadata['backup_type'] ?? 'full',
|
|
'destination_id' => $schedule->destination_id,
|
|
'schedule_id' => $schedule->id,
|
|
'include_files' => $schedule->include_files,
|
|
'include_databases' => $schedule->include_databases,
|
|
'include_mailboxes' => $schedule->include_mailboxes,
|
|
'include_dns' => $schedule->include_dns,
|
|
'users' => $schedule->users,
|
|
]);
|
|
}
|
|
|
|
public function stopScheduleBackup(int $id): void
|
|
{
|
|
$backup = Backup::where('schedule_id', $id)->running()->first();
|
|
if ($backup) {
|
|
$backup->update([
|
|
'status' => 'failed',
|
|
'error_message' => __('Cancelled by user'),
|
|
'completed_at' => now(),
|
|
]);
|
|
Notification::make()->title(__('Backup cancelled'))->success()->send();
|
|
$this->resetTable();
|
|
}
|
|
}
|
|
|
|
public function createUserBackupAction(): Action
|
|
{
|
|
return Action::make('createUserBackup')
|
|
->label(__('Backup User'))
|
|
->icon('heroicon-o-user')
|
|
->color('gray')
|
|
->form([
|
|
Select::make('user_id')
|
|
->label(__('User'))
|
|
->options(fn () => User::where('is_admin', false)
|
|
->where('is_active', true)
|
|
->pluck('username', 'id'))
|
|
->required()
|
|
->searchable(),
|
|
Section::make(__('Include'))
|
|
->schema([
|
|
Grid::make(2)->schema([
|
|
Toggle::make('include_files')->label(__('Website Files'))->default(true),
|
|
Toggle::make('include_databases')->label(__('Databases'))->default(true),
|
|
Toggle::make('include_mailboxes')->label(__('Mailboxes'))->default(true),
|
|
Toggle::make('include_dns')->label(__('DNS Records'))->default(true),
|
|
]),
|
|
]),
|
|
])
|
|
->action(function (array $data) {
|
|
$user = User::find($data['user_id']);
|
|
if (! $user) {
|
|
Notification::make()->title(__('User not found'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$timestamp = now()->format('Y-m-d_His');
|
|
$filename = "backup_{$timestamp}.tar.gz";
|
|
$outputPath = "/home/{$user->username}/backups/{$filename}";
|
|
|
|
$backup = Backup::create([
|
|
'user_id' => $user->id,
|
|
'name' => "{$user->username} ".__('Backup').' '.now()->format('Y-m-d H:i'),
|
|
'filename' => $filename,
|
|
'type' => 'full',
|
|
'include_files' => $data['include_files'] ?? true,
|
|
'include_databases' => $data['include_databases'] ?? true,
|
|
'include_mailboxes' => $data['include_mailboxes'] ?? true,
|
|
'include_dns' => $data['include_dns'] ?? true,
|
|
'status' => 'pending',
|
|
'local_path' => $outputPath,
|
|
'metadata' => ['backup_type' => 'full'],
|
|
]);
|
|
|
|
try {
|
|
$backup->update(['status' => 'running', 'started_at' => now()]);
|
|
|
|
$result = $this->getAgent()->backupCreate($user->username, $outputPath, [
|
|
'backup_type' => 'full',
|
|
'include_files' => $data['include_files'] ?? true,
|
|
'include_databases' => $data['include_databases'] ?? true,
|
|
'include_mailboxes' => $data['include_mailboxes'] ?? true,
|
|
'include_dns' => $data['include_dns'] ?? true,
|
|
]);
|
|
|
|
if ($result['success']) {
|
|
$backup->update([
|
|
'status' => 'completed',
|
|
'completed_at' => now(),
|
|
'size_bytes' => $result['size'] ?? 0,
|
|
'checksum' => $result['checksum'] ?? null,
|
|
'domains' => $result['domains'] ?? null,
|
|
'databases' => $result['databases'] ?? null,
|
|
'mailboxes' => $result['mailboxes'] ?? null,
|
|
]);
|
|
Notification::make()->title(__('Backup created for :username', ['username' => $user->username]))->success()->send();
|
|
} else {
|
|
throw new Exception($result['error'] ?? __('Backup failed'));
|
|
}
|
|
} catch (Exception $e) {
|
|
$backup->update([
|
|
'status' => 'failed',
|
|
'completed_at' => now(),
|
|
'error_message' => $e->getMessage(),
|
|
]);
|
|
Notification::make()->title(__('Backup failed'))->body($e->getMessage())->danger()->send();
|
|
}
|
|
|
|
$this->resetTable();
|
|
});
|
|
}
|
|
|
|
protected function executeRestore(Backup $backup, array $data): void
|
|
{
|
|
// Use username from form (allows selecting user for server backups)
|
|
$username = $data['restore_username'] ?? '';
|
|
|
|
if ($username === '__all__') {
|
|
$manifest = $this->getBackupManifest($backup);
|
|
$usernames = $manifest['users'] ?? $backup->users ?? [];
|
|
if (empty($usernames)) {
|
|
$usernames = array_filter([$manifest['username'] ?? '']);
|
|
}
|
|
|
|
if (empty($usernames)) {
|
|
Notification::make()->title(__('Cannot determine users for this backup'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($usernames as $restoreUser) {
|
|
$data['restore_username'] = $restoreUser;
|
|
$this->executeRestore($backup, $data);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (empty($username)) {
|
|
$manifest = $this->getBackupManifest($backup);
|
|
$username = $manifest['username'] ?? ($backup->users[0] ?? '');
|
|
}
|
|
|
|
if (empty($username)) {
|
|
Notification::make()->title(__('Cannot determine user for this backup'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
// Prepare backup path
|
|
$backupPath = $backup->local_path;
|
|
$tempDownloadPath = null;
|
|
|
|
// For remote backups, download first
|
|
if ((! $backupPath || ! file_exists($backupPath)) && $backup->remote_path && $backup->destination) {
|
|
$destination = $backup->destination;
|
|
|
|
if (! $destination) {
|
|
Notification::make()->title(__('Backup destination not found'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
// Create temp directory for download
|
|
$tempDownloadPath = sys_get_temp_dir().'/jabali_restore_download_'.uniqid();
|
|
mkdir($tempDownloadPath, 0755, true);
|
|
|
|
// For incremental backups, we need to download the specific user's directory
|
|
$remotePath = $backup->remote_path;
|
|
|
|
// If it's a server backup with per-user directories, construct the user-specific path
|
|
if (str_contains($remotePath, '/') && ! str_ends_with($remotePath, '.tar.gz')) {
|
|
// Incremental backup - download the user's directory
|
|
$userRemotePath = rtrim($remotePath, '/').'/'.$username;
|
|
} else {
|
|
$userRemotePath = $remotePath;
|
|
}
|
|
|
|
Notification::make()
|
|
->title(__('Downloading backup'))
|
|
->body(__('Downloading from remote destination...'))
|
|
->info()
|
|
->send();
|
|
|
|
try {
|
|
$downloadResult = $this->getAgent()->send('backup.download_remote', [
|
|
'remote_path' => $userRemotePath,
|
|
'local_path' => $tempDownloadPath,
|
|
'destination' => array_merge(
|
|
$destination->config ?? [],
|
|
['type' => $destination->type]
|
|
),
|
|
]);
|
|
|
|
if (! ($downloadResult['success'] ?? false)) {
|
|
throw new Exception($downloadResult['error'] ?? __('Failed to download backup'));
|
|
}
|
|
|
|
$backupPath = $tempDownloadPath;
|
|
} catch (Exception $e) {
|
|
// Cleanup temp directory on failure
|
|
if (is_dir($tempDownloadPath)) {
|
|
exec('rm -rf '.escapeshellarg($tempDownloadPath));
|
|
}
|
|
Notification::make()
|
|
->title(__('Download failed'))
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (! $backupPath || ! file_exists($backupPath)) {
|
|
Notification::make()->title(__('Backup file not found'))->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$result = $this->getAgent()->send('backup.restore', [
|
|
'username' => $username,
|
|
'backup_path' => $backupPath,
|
|
'restore_files' => $data['restore_files'] ?? false,
|
|
'restore_databases' => $data['restore_databases'] ?? false,
|
|
'restore_mailboxes' => $data['restore_mailboxes'] ?? false,
|
|
'restore_dns' => $data['restore_dns'] ?? false,
|
|
'restore_ssl' => $data['restore_ssl'] ?? false,
|
|
'selected_domains' => ! empty($data['selected_domains']) ? $data['selected_domains'] : null,
|
|
'selected_databases' => ! empty($data['selected_databases']) ? $data['selected_databases'] : null,
|
|
'selected_mailboxes' => ! empty($data['selected_mailboxes']) ? $data['selected_mailboxes'] : null,
|
|
]);
|
|
|
|
// Cleanup temp download if used
|
|
if ($tempDownloadPath && is_dir($tempDownloadPath)) {
|
|
exec('rm -rf '.escapeshellarg($tempDownloadPath));
|
|
}
|
|
|
|
if ($result['success'] ?? false) {
|
|
$restored = $result['restored'] ?? [];
|
|
$summary = [];
|
|
|
|
if (! empty($restored['files'])) {
|
|
$summary[] = count($restored['files']).' '.__('domain(s)');
|
|
}
|
|
if (! empty($restored['databases'])) {
|
|
$summary[] = count($restored['databases']).' '.__('database(s)');
|
|
}
|
|
if (! empty($restored['mailboxes'])) {
|
|
$summary[] = count($restored['mailboxes']).' '.__('mailbox(es)');
|
|
}
|
|
if (! empty($restored['ssl_certificates'])) {
|
|
$summary[] = count($restored['ssl_certificates']).' '.__('SSL cert(s)');
|
|
}
|
|
if (! empty($restored['dns_zones'])) {
|
|
$summary[] = count($restored['dns_zones']).' '.__('DNS zone(s)');
|
|
}
|
|
if ($restored['mysql_users'] ?? false) {
|
|
$summary[] = __('MySQL users');
|
|
}
|
|
|
|
Notification::make()
|
|
->title(__('Restore completed'))
|
|
->body(! empty($summary) ? __('Restored: :items', ['items' => implode(', ', $summary)]) : __('Nothing was restored'))
|
|
->success()
|
|
->send();
|
|
} else {
|
|
throw new Exception($result['error'] ?? __('Restore failed'));
|
|
}
|
|
} catch (Exception $e) {
|
|
// Cleanup temp download on failure
|
|
if ($tempDownloadPath && is_dir($tempDownloadPath)) {
|
|
exec('rm -rf '.escapeshellarg($tempDownloadPath));
|
|
}
|
|
Notification::make()
|
|
->title(__('Restore failed'))
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
}
|
|
}
|
|
|
|
protected function getBackupManifest(Backup $backup, ?string $forUser = null): array
|
|
{
|
|
$backupPath = $backup->local_path;
|
|
|
|
if (! $backupPath || ! file_exists($backupPath)) {
|
|
// For remote backups, try to get info from stored metadata
|
|
return [
|
|
'username' => $forUser ?? ($backup->users[0] ?? ''),
|
|
'domains' => $backup->domains ?? [],
|
|
'databases' => $backup->databases ?? [],
|
|
'mailboxes' => $backup->mailboxes ?? [],
|
|
'mysql_users' => $backup->metadata['mysql_users'] ?? [],
|
|
'ssl_certificates' => $backup->ssl_certificates ?? [],
|
|
'dns_zones' => $backup->dns_zones ?? [],
|
|
'users' => $backup->users ?? [],
|
|
];
|
|
}
|
|
|
|
try {
|
|
$result = $this->getAgent()->send('backup.get_info', [
|
|
'backup_path' => $backupPath,
|
|
]);
|
|
|
|
if ($result['success'] ?? false) {
|
|
$manifest = $result['manifest'] ?? [];
|
|
|
|
// Handle server backup manifest format (has nested 'users' object)
|
|
if (isset($manifest['users']) && is_array($manifest['users']) && $manifest['type'] === 'server') {
|
|
$userList = array_keys($manifest['users']);
|
|
|
|
// If no specific user requested, return aggregated data
|
|
if ($forUser === null) {
|
|
return [
|
|
'username' => $userList[0] ?? '',
|
|
'users' => $userList,
|
|
'type' => 'server',
|
|
'domains' => $this->aggregateFromUsers($manifest['users'], 'domains'),
|
|
'databases' => $this->aggregateFromUsers($manifest['users'], 'databases'),
|
|
'mailboxes' => $this->aggregateFromUsers($manifest['users'], 'mailboxes'),
|
|
'mysql_users' => [],
|
|
'ssl_certificates' => [],
|
|
'dns_zones' => [],
|
|
];
|
|
}
|
|
|
|
// Return specific user's data
|
|
if (isset($manifest['users'][$forUser])) {
|
|
$userData = $manifest['users'][$forUser];
|
|
|
|
return [
|
|
'username' => $forUser,
|
|
'users' => $userList,
|
|
'type' => 'server',
|
|
'domains' => $userData['domains'] ?? [],
|
|
'databases' => $userData['databases'] ?? [],
|
|
'mailboxes' => $userData['mailboxes'] ?? [],
|
|
'mysql_users' => [],
|
|
'ssl_certificates' => [],
|
|
'dns_zones' => [],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $manifest;
|
|
}
|
|
} catch (Exception $e) {
|
|
// Fall back to stored data
|
|
}
|
|
|
|
return [
|
|
'username' => $forUser ?? ($backup->users[0] ?? ''),
|
|
'domains' => $backup->domains ?? [],
|
|
'databases' => $backup->databases ?? [],
|
|
'mailboxes' => $backup->mailboxes ?? [],
|
|
'mysql_users' => [],
|
|
'ssl_certificates' => [],
|
|
'dns_zones' => [],
|
|
'users' => $backup->users ?? [],
|
|
];
|
|
}
|
|
|
|
protected function aggregateFromUsers(array $users, string $key): array
|
|
{
|
|
$result = [];
|
|
foreach ($users as $userData) {
|
|
if (isset($userData[$key]) && is_array($userData[$key])) {
|
|
$result = array_merge($result, $userData[$key]);
|
|
}
|
|
}
|
|
|
|
return array_unique($result);
|
|
}
|
|
|
|
/**
|
|
* Get the system timezone for display purposes.
|
|
* Laravel uses UTC internally but we display times in server's local timezone.
|
|
*/
|
|
protected function getSystemTimezone(): string
|
|
{
|
|
static $timezone = null;
|
|
if ($timezone === null) {
|
|
$timezone = trim(shell_exec('cat /etc/timezone 2>/dev/null') ?? '');
|
|
if ($timezone === '') {
|
|
$timezone = trim(shell_exec('timedatectl show -p Timezone --value 2>/dev/null') ?? '');
|
|
}
|
|
if ($timezone === '') {
|
|
$timezone = 'UTC';
|
|
}
|
|
}
|
|
|
|
return $timezone;
|
|
}
|
|
}
|