label(__('Start Over')) ->icon('heroicon-o-arrow-path') ->color('gray') ->requiresConfirmation() ->modalHeading(__('Start Over')) ->modalDescription(__('This will reset the DirectAdmin migration wizard. Are you sure?')) ->action('resetMigration'), ]; } public function mount(): void { $this->restoreFromSession(); $this->restoreFromImport(); if ($this->importMethod === 'backup_file') { $this->loadLocalBackups(); } } public function updatedImportMethod(): void { $this->remoteHost = null; $this->remotePort = 2222; $this->remoteUser = null; $this->remotePassword = null; $this->localBackupPath = null; $this->backupPath = null; $this->availableBackups = []; if ($this->importMethod === 'backup_file') { $this->loadLocalBackups(); } } public function updatedLocalBackupPath(): void { if (! $this->localBackupPath) { $this->backupPath = null; return; } $this->selectLocalBackup(); } protected function getForms(): array { return ['migrationForm']; } public function migrationForm(Schema $schema): Schema { return $schema->schema([ Wizard::make([ $this->getConnectStep(), $this->getConfigureStep(), $this->getMigrateStep(), ]) ->persistStepInQueryString('directadmin-step'), ]); } protected function getConnectStep(): Step { return Step::make(__('Connect')) ->id('connect') ->icon('heroicon-o-link') ->description(__('Connect to DirectAdmin or upload a backup')) ->schema([ Section::make(__('Source')) ->description(__('For now, migration requires a DirectAdmin backup archive. Remote migration will be added next.')) ->icon('heroicon-o-server') ->schema([ Radio::make('importMethod') ->label(__('Import Method')) ->options([ 'backup_file' => __('Backup File'), 'remote_server' => __('Remote Server (Discovery only)'), ]) ->default('backup_file') ->live(), Grid::make(['default' => 1, 'sm' => 2]) ->schema([ TextInput::make('remoteHost') ->label(__('Host')) ->placeholder('directadmin.example.com') ->required() ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), TextInput::make('remotePort') ->label(__('Port')) ->numeric() ->default(2222) ->required() ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), TextInput::make('remoteUser') ->label(__('Username')) ->required() ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), TextInput::make('remotePassword') ->label(__('Password')) ->password() ->revealable() ->required() ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), ]), Section::make(__('Backup File')) ->description(__('Upload your DirectAdmin backup archive to your backups folder, then select it here.')) ->icon('heroicon-o-folder') ->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file') ->headerActions([ Action::make('refreshLocalBackups') ->label(__('Refresh')) ->icon('heroicon-o-arrow-path') ->color('gray') ->action('refreshLocalBackups'), ]) ->schema([ Select::make('localBackupPath') ->label(__('Backup File')) ->options(fn (): array => $this->getLocalBackupOptions()) ->searchable() ->required(fn (Get $get): bool => $get('importMethod') === 'backup_file') ->live(), Text::make(fn (): string => $this->backupPath ? __('Selected file: :file', ['file' => basename($this->backupPath)]) : __('No backup selected yet.')) ->color('gray'), Text::make(fn (): string => ($user = $this->getUser()) ? __('Upload the file to: /home/:user/backups', ['user' => $user->username]) : __('Upload the file to your /home//backups folder.')) ->color('gray'), Text::make(__('Supported formats: .tar.zst, .tar.gz, .tgz'))->color('gray'), Text::make(fn (): string => ($user = $this->getUser()) ? __('No backups found in /home/:user/backups. Upload a file there and click Refresh.', ['user' => $user->username]) : __('No backups found.')) ->color('gray') ->visible(fn (): bool => empty($this->availableBackups)), ]), FormActions::make([ Action::make('discoverAccount') ->label(__('Discover Account')) ->icon('heroicon-o-magnifying-glass') ->color('primary') ->action('discoverAccount'), ])->alignEnd(), ]), Section::make(__('Discovery')) ->description(__('After discovery, you can choose what to import.')) ->icon('heroicon-o-user') ->schema([ Text::make(__('Discovered account details will be used for migration.'))->color('gray'), ]), ]) ->afterValidation(function () { $import = $this->getImport(); $hasAccounts = $import?->accounts()->exists() ?? false; if (! $hasAccounts) { Notification::make() ->title(__('No account discovered')) ->body(__('Click "Discover Account" to continue.')) ->danger() ->send(); throw new Exception(__('No account discovered')); } $this->step1Complete = true; $this->saveToSession(); }); } protected function getConfigureStep(): Step { return Step::make(__('Configure')) ->id('configure') ->icon('heroicon-o-cog') ->description(__('Choose what to import')) ->schema([ Section::make(__('What to Import')) ->description(__('Select which parts of your account to import.')) ->icon('heroicon-o-check-circle') ->schema([ Grid::make(['default' => 1, 'sm' => 2])->schema([ Checkbox::make('importFiles') ->label(__('Website Files')) ->helperText(__('Restore website files from the backup')) ->default(true), Checkbox::make('importDatabases') ->label(__('Databases')) ->helperText(__('Restore MySQL databases and import dumps')) ->default(true), Checkbox::make('importEmails') ->label(__('Email')) ->helperText(__('Create email domains and mailboxes (limited in Phase 1)')) ->default(true), Checkbox::make('importSsl') ->label(__('SSL')) ->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)')) ->default(true), ]), ]), ]) ->afterValidation(function (): void { $import = $this->getImport(); if (! $import) { throw new Exception(__('Import job not found')); } $import->update([ 'import_options' => [ 'files' => $this->importFiles, 'databases' => $this->importDatabases, 'emails' => $this->importEmails, 'ssl' => $this->importSsl, ], ]); $this->saveToSession(); }); } protected function getMigrateStep(): Step { return Step::make(__('Migrate')) ->id('migrate') ->icon('heroicon-o-play') ->description(__('Run the migration and watch progress')) ->schema([ FormActions::make([ Action::make('startMigration') ->label(__('Start Migration')) ->icon('heroicon-o-play') ->color('success') ->requiresConfirmation() ->modalHeading(__('Start Migration')) ->modalDescription(__('This will import data into your Jabali account. Continue?')) ->action('startMigration'), Action::make('newMigration') ->label(__('New Migration')) ->icon('heroicon-o-plus') ->color('primary') ->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed') ->action('resetMigration'), ])->alignEnd(), Section::make(__('Import Status')) ->icon('heroicon-o-queue-list') ->schema([ View::make('filament.jabali.pages.directadmin-migration-status-table'), ]), ]); } public function discoverAccount(): void { try { $user = Auth::user(); if (! $user) { throw new Exception(__('You must be logged in.')); } $import = $this->upsertImportForDiscovery(); $backupFullPath = null; $remotePassword = null; if ($this->importMethod === 'backup_file') { if (! $import->backup_path) { throw new Exception(__('Please select a DirectAdmin backup archive.')); } $backupFullPath = $this->resolveBackupFullPath($import->backup_path); if (! $backupFullPath) { throw new Exception(__('Backup file not found: :path', ['path' => $import->backup_path])); } } else { $remotePassword = $this->remotePassword; if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) { $remotePassword = (string) $import->remote_password; } if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) { throw new Exception(__('Please enter DirectAdmin host, port, username and password.')); } } $result = $this->getAgent()->importDiscover( $import->id, 'directadmin', $import->import_method, $backupFullPath, $import->remote_host, $import->remote_port ? (int) $import->remote_port : null, $import->remote_user, $remotePassword, ); if (! ($result['success'] ?? false)) { throw new Exception((string) ($result['error'] ?? __('Discovery failed'))); } $accounts = $result['accounts'] ?? []; if (! is_array($accounts) || $accounts === []) { throw new Exception(__('No account was discovered.')); } $account = null; if (count($accounts) === 1) { $account = $accounts[0]; } else { // Prefer matching the provided username if multiple accounts are returned. foreach ($accounts as $candidate) { if (! is_array($candidate)) { continue; } if (($candidate['username'] ?? null) === $this->remoteUser) { $account = $candidate; break; } } } if (! is_array($account)) { throw new Exception(__('Multiple accounts were discovered. Please upload a single-user backup archive.')); } $sourceUsername = trim((string) ($account['username'] ?? '')); if ($sourceUsername === '') { throw new Exception(__('Discovered account is missing a username.')); } $import->accounts()->delete(); $record = ServerImportAccount::create([ 'server_import_id' => $import->id, 'source_username' => $sourceUsername, 'target_username' => $user->username, 'email' => (string) ($account['email'] ?? ''), 'main_domain' => (string) ($account['main_domain'] ?? ''), 'addon_domains' => $account['addon_domains'] ?? [], 'subdomains' => $account['subdomains'] ?? [], 'databases' => $account['databases'] ?? [], 'email_accounts' => $account['email_accounts'] ?? [], 'disk_usage' => (int) ($account['disk_usage'] ?? 0), 'status' => 'pending', 'progress' => 0, 'current_task' => null, 'import_log' => [], 'error' => null, ]); $import->update([ 'discovered_accounts' => [$account], 'selected_accounts' => [$record->id], 'status' => 'ready', 'progress' => 0, 'current_task' => null, 'errors' => [], ]); $this->importId = $import->id; $this->step1Complete = true; $this->saveToSession(); $this->dispatch('directadmin-self-status-updated'); Notification::make() ->title(__('Account discovered')) ->body(__('Ready to migrate into your Jabali account (:username).', ['username' => $user->username])) ->success() ->send(); } catch (Exception $e) { Notification::make() ->title(__('Discovery failed')) ->body($e->getMessage()) ->danger() ->send(); } } public function startMigration(): void { $import = $this->getImport(); if (! $import) { Notification::make() ->title(__('Import job not found')) ->danger() ->send(); return; } $selected = $import->selected_accounts ?? []; if (! is_array($selected) || $selected === []) { Notification::make() ->title(__('No account selected')) ->danger() ->send(); return; } if ($import->import_method === 'remote_server') { Notification::make() ->title(__('Remote DirectAdmin import is not available yet')) ->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.')) ->warning() ->send(); return; } $import->update([ 'status' => 'importing', 'started_at' => now(), ]); $result = $this->getAgent()->importStart($import->id); if (! ($result['success'] ?? false)) { Notification::make() ->title(__('Failed to start migration')) ->body((string) ($result['error'] ?? __('Unknown error'))) ->danger() ->send(); return; } Notification::make() ->title(__('Migration started')) ->body(__('Import process has started in the background.')) ->success() ->send(); $this->dispatch('directadmin-self-status-updated'); } public function resetMigration(): void { if ($this->importId) { ServerImport::whereKey($this->importId)->delete(); } session()->forget('directadmin_self_migration.import_id'); $this->wizardStep = null; $this->step1Complete = false; $this->importId = null; $this->importMethod = 'backup_file'; $this->remoteHost = null; $this->remotePort = 2222; $this->remoteUser = null; $this->remotePassword = null; $this->localBackupPath = null; $this->availableBackups = []; $this->backupPath = null; $this->importFiles = true; $this->importDatabases = true; $this->importEmails = true; $this->importSsl = true; } protected function getAgent(): AgentClient { return $this->agent ??= new AgentClient; } protected function getUser() { return Auth::user(); } protected function loadLocalBackups(): void { $this->availableBackups = []; $user = $this->getUser(); if (! $user) { return; } $result = $this->getAgent()->send('file.list', [ 'username' => $user->username, 'path' => 'backups', ]); if (! ($result['success'] ?? false)) { $this->getAgent()->send('file.mkdir', [ 'username' => $user->username, 'path' => 'backups', ]); $result = $this->getAgent()->send('file.list', [ 'username' => $user->username, 'path' => 'backups', ]); if (! ($result['success'] ?? false)) { return; } } $items = $result['items'] ?? []; foreach ($items as $item) { if (($item['is_dir'] ?? false) === true) { continue; } $name = (string) ($item['name'] ?? ''); if (! preg_match('/\\.(tar\\.zst|tar\\.gz|tgz)$/i', $name)) { continue; } $this->availableBackups[] = $item; } } public function refreshLocalBackups(): void { $this->loadLocalBackups(); Notification::make() ->title(__('Backup list refreshed')) ->success() ->send(); } protected function getLocalBackupOptions(): array { $options = []; foreach ($this->availableBackups as $item) { $path = $item['path'] ?? null; $name = $item['name'] ?? null; if (! $path || ! $name) { continue; } $size = $this->formatBytes((int) ($item['size'] ?? 0)); $options[$path] = "{$name} ({$size})"; } return $options; } protected function selectLocalBackup(): void { $user = $this->getUser(); if (! $user || ! $this->localBackupPath) { return; } $info = $this->getAgent()->send('file.info', [ 'username' => $user->username, 'path' => $this->localBackupPath, ]); if (! ($info['success'] ?? false)) { Notification::make() ->title(__('Backup file not found')) ->body($info['error'] ?? __('Unable to read backup file')) ->danger() ->send(); $this->backupPath = null; return; } $details = $info['info'] ?? []; if (! ($details['is_file'] ?? false)) { Notification::make() ->title(__('Invalid backup selection')) ->body(__('Please select a backup file')) ->warning() ->send(); $this->backupPath = null; return; } $this->backupPath = "/home/{$user->username}/{$this->localBackupPath}"; Notification::make() ->title(__('Backup selected')) ->body(__('Selected :name (:size)', [ 'name' => $details['name'] ?? basename($this->backupPath), 'size' => $this->formatBytes((int) ($details['size'] ?? 0)), ])) ->success() ->send(); } protected function formatBytes(int $bytes): string { if ($bytes >= 1073741824) { return number_format($bytes / 1073741824, 2).' GB'; } if ($bytes >= 1048576) { return number_format($bytes / 1048576, 2).' MB'; } if ($bytes >= 1024) { return number_format($bytes / 1024, 2).' KB'; } return $bytes.' B'; } protected function resolveBackupFullPath(?string $path): ?string { $path = trim((string) ($path ?? '')); if ($path === '') { return null; } if (str_starts_with($path, '/') && file_exists($path)) { return $path; } $localCandidate = Storage::disk('local')->path($path); if (file_exists($localCandidate)) { return $localCandidate; } $backupCandidate = Storage::disk('backups')->path($path); if (file_exists($backupCandidate)) { return $backupCandidate; } return file_exists($path) ? $path : null; } protected function getImport(): ?ServerImport { if (! $this->importId) { return null; } return ServerImport::with('accounts')->find($this->importId); } protected function upsertImportForDiscovery(): ServerImport { $user = Auth::user(); $name = $user ? ('DirectAdmin Import - '.$user->username.' - '.now()->format('Y-m-d H:i')) : ('DirectAdmin Import '.now()->format('Y-m-d H:i')); $attributes = [ 'name' => $name, 'source_type' => 'directadmin', 'import_method' => $this->importMethod, 'import_options' => [ 'files' => $this->importFiles, 'databases' => $this->importDatabases, 'emails' => $this->importEmails, 'ssl' => $this->importSsl, ], 'status' => 'discovering', 'progress' => 0, 'current_task' => null, ]; if ($this->importMethod === 'backup_file') { $attributes['backup_path'] = $this->backupPath; $attributes['remote_host'] = null; $attributes['remote_port'] = null; $attributes['remote_user'] = null; } else { $attributes['backup_path'] = null; $attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null; $attributes['remote_port'] = $this->remotePort; $attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null; if (filled($this->remotePassword)) { $attributes['remote_password'] = $this->remotePassword; } } $import = $this->importId ? ServerImport::find($this->importId) : null; if ($import) { $import->update($attributes); } else { $import = ServerImport::create($attributes); $this->importId = $import->id; } $this->saveToSession(); return $import->fresh(); } protected function saveToSession(): void { if ($this->importId) { session()->put('directadmin_self_migration.import_id', $this->importId); } session()->save(); } protected function restoreFromSession(): void { $this->importId = session('directadmin_self_migration.import_id'); } protected function restoreFromImport(): void { $import = $this->getImport(); if (! $import) { return; } $this->importMethod = (string) ($import->import_method ?? 'backup_file'); $this->backupPath = $import->backup_path; if ($this->backupPath && ($user = $this->getUser())) { $prefix = "/home/{$user->username}/"; if (str_starts_with($this->backupPath, $prefix)) { $this->localBackupPath = ltrim(substr($this->backupPath, strlen($prefix)), '/'); } } $this->remoteHost = $import->remote_host; $this->remotePort = (int) ($import->remote_port ?? 2222); $this->remoteUser = $import->remote_user; $options = $import->import_options ?? []; if (is_array($options)) { $this->importFiles = (bool) ($options['files'] ?? true); $this->importDatabases = (bool) ($options['databases'] ?? true); $this->importEmails = (bool) ($options['emails'] ?? true); $this->importSsl = (bool) ($options['ssl'] ?? true); } $this->step1Complete = $import->accounts()->exists(); } }