['jabali_username' => '', 'email' => '', 'password' => ''] // Migration status (Step 4) public bool $isMigrating = false; public array $migrationStatus = []; // user => ['status' => 'pending|processing|completed|error', 'log' => [], 'progress' => 0] public int $currentAccountIndex = 0; public int $totalAccounts = 0; public array $statusLog = []; protected ?AgentClient $agent = null; protected ?WhmApiService $whm = null; public function getTitle(): string|Htmlable { return __('WHM Migration'); } public function getSubheading(): ?string { return __('Migrate multiple cPanel accounts from a WHM server to Jabali'); } protected function getHeaderActions(): array { return [ Action::make('startOver') ->label(__('Start Over')) ->icon('heroicon-o-arrow-path') ->color('gray') ->requiresConfirmation() ->modalHeading(__('Start Over')) ->modalDescription(__('This will reset all migration data. Are you sure?')) ->action('resetMigration'), ]; } public function mount(): void { $this->restoreFromSession(); $this->loadMigrationStatusFromStore(); // Initialize accountConfig if we have selectedAccounts but no config // This handles direct URL navigation to step 3 if (! empty($this->selectedAccounts) && empty($this->accountConfig)) { $this->initializeAccountConfig(); $this->saveToSession(); } } public function updatedHostname(): void { $this->resetConnection(); } public function updatedWhmUsername(): void { $this->resetConnection(); } public function updatedApiToken(): void { $this->resetConnection(); } public function updatedPort(): void { $this->resetConnection(); } public function updatedUseSSL(): void { $this->resetConnection(); } protected function resetConnection(): void { $this->whm = null; $this->isConnected = false; $this->serverInfo = []; $this->accounts = []; $this->selectedAccounts = []; } public function getAgent(): AgentClient { return $this->agent ??= new AgentClient; } public function getMigrationCacheKey(): string { $userId = auth()->id() ?? 0; return 'whm_migration_status_'.$userId; } protected function getMigrationStatusStore(): WhmMigrationStatusStore { return new WhmMigrationStatusStore($this->getMigrationCacheKey()); } protected function loadMigrationStatusFromStore(): void { $state = $this->getMigrationStatusStore()->get(); if ($state === []) { return; } $this->migrationStatus = $state['migrationStatus'] ?? $this->migrationStatus; $this->isMigrating = (bool) ($state['isMigrating'] ?? $this->isMigrating); $this->selectedAccounts = $state['selectedAccounts'] ?? $this->selectedAccounts; } protected function getWhm(): ?WhmApiService { if (! $this->hostname || ! $this->whmUsername || ! $this->apiToken) { $this->restoreFromSession(); } if (! $this->hostname || ! $this->whmUsername || ! $this->apiToken) { return null; } return $this->whm ??= new WhmApiService( trim($this->hostname), trim($this->whmUsername), trim($this->apiToken), $this->port, $this->useSSL ); } protected function saveToSession(): void { session()->put('whm_migration.hostname', $this->hostname); session()->put('whm_migration.username', $this->whmUsername); session()->put('whm_migration.token', $this->apiToken); session()->put('whm_migration.port', $this->port); session()->put('whm_migration.useSSL', $this->useSSL); session()->put('whm_migration.isConnected', $this->isConnected); session()->put('whm_migration.serverInfo', $this->serverInfo); session()->put('whm_migration.accounts', $this->accounts); session()->put('whm_migration.selectedAccounts', $this->selectedAccounts); session()->put('whm_migration.accountConfig', $this->accountConfig); session()->put('whm_migration.step1Complete', $this->step1Complete); session()->put('whm_migration.step2Complete', $this->step2Complete); session()->put('whm_migration.migrationStatus', $this->migrationStatus); session()->put('whm_migration.isMigrating', $this->isMigrating); session()->save(); } protected function restoreFromSession(): void { if (session()->has('whm_migration.hostname')) { $this->hostname = session('whm_migration.hostname'); $this->whmUsername = session('whm_migration.username', 'root'); $this->apiToken = session('whm_migration.token'); $this->port = session('whm_migration.port', 2087); $this->useSSL = session('whm_migration.useSSL', true); $this->isConnected = session('whm_migration.isConnected', false); $this->serverInfo = session('whm_migration.serverInfo', []); $this->accounts = session('whm_migration.accounts', []); $this->selectedAccounts = session('whm_migration.selectedAccounts', []); $this->accountConfig = session('whm_migration.accountConfig', []); $this->step1Complete = session('whm_migration.step1Complete', false); $this->step2Complete = session('whm_migration.step2Complete', false); $this->migrationStatus = session('whm_migration.migrationStatus', []); } } protected function clearSession(): void { session()->forget([ 'whm_migration.hostname', 'whm_migration.username', 'whm_migration.token', 'whm_migration.port', 'whm_migration.useSSL', 'whm_migration.isConnected', 'whm_migration.serverInfo', 'whm_migration.accounts', 'whm_migration.selectedAccounts', 'whm_migration.accountConfig', 'whm_migration.step1Complete', 'whm_migration.step2Complete', 'whm_migration.migrationStatus', ]); } protected function getBackupDestPath(): string { return '/var/backups/jabali/whm-migrations'; } protected function getJabaliPublicIp(): string { $ip = trim(shell_exec('curl -s ifconfig.me 2>/dev/null') ?? ''); if (empty($ip)) { $ip = gethostbyname(gethostname()); } return $ip; } protected function getSshKeyName(): string { return 'jabali-system-key'; } protected function getForms(): array { return ['migrationForm']; } public function migrationForm(Schema $schema): Schema { return $schema->schema([ Wizard::make([ $this->getConnectStep(), $this->getSelectAccountsStep(), $this->getConfigureStep(), $this->getMigrateStep(), ]) ->nextAction( fn (Action $action) => $action ->disabled(fn () => $this->isNextStepDisabled()) ->hidden(fn () => $this->isNextButtonHidden()) ) ->persistStepInQueryString('whm-step'), ]); } protected function isNextButtonHidden(): bool { return $this->isMigrating; } protected function isNextStepDisabled(): bool { // Step 1: Must be connected if (! $this->step1Complete) { return ! $this->isConnected; } // Step 2: Must have selected accounts if (! $this->step2Complete) { return empty($this->selectedAccounts); } return false; } protected function getConnectStep(): Step { return Step::make(__('Connect')) ->id('connect') ->icon('heroicon-o-link') ->description(__('Connect to WHM server')) ->schema([ Section::make(__('WHM Server Credentials')) ->description(__('Enter the WHM (WebHost Manager) server connection details')) ->icon('heroicon-o-server-stack') ->schema([ Grid::make(['default' => 1, 'sm' => 2])->schema([ TextInput::make('hostname') ->label(__('WHM Hostname')) ->placeholder('whm.example.com') ->required() ->helperText(__('Your WHM server hostname or IP address')), TextInput::make('port') ->label(__('Port')) ->numeric() ->default(2087) ->required() ->helperText(__('Usually 2087 for SSL or 2086 without')), ]), Grid::make(['default' => 1, 'sm' => 2])->schema([ TextInput::make('whmUsername') ->label(__('WHM Username')) ->default('root') ->required() ->helperText(__('Usually "root" for WHM')), TextInput::make('apiToken') ->label(__('API Token')) ->password() ->required() ->revealable() ->helperText(__('Generate from WHM → Manage API Tokens')), ]), Checkbox::make('useSSL') ->label(__('Use SSL (HTTPS)')) ->default(true) ->helperText(__('Recommended. Disable only if your WHM does not support SSL.')), ]), FormActions::make([ Action::make('testConnection') ->label(fn () => $this->isConnected ? __('Connected') : __('Test Connection')) ->icon(fn () => $this->isConnected ? 'heroicon-o-check-circle' : 'heroicon-o-signal') ->color(fn () => $this->isConnected ? 'success' : 'primary') ->action('testConnection'), ])->alignEnd(), Section::make(__('Connection Successful')) ->icon('heroicon-o-check-circle') ->iconColor('success') ->visible(fn () => $this->isConnected) ->schema([ Grid::make(['default' => 2, 'sm' => 4])->schema([ Text::make(fn () => __('Host: :host', ['host' => $this->hostname ?? '-'])), Text::make(fn () => __('Version: :version', ['version' => $this->serverInfo['version'] ?? '-'])), Text::make(fn () => __('Accounts: :count', ['count' => $this->serverInfo['account_count'] ?? 0])), Text::make(fn () => __('User: :user', ['user' => $this->whmUsername ?? '-'])), ]), Text::make(__('You can proceed to select accounts for migration.'))->color('success'), ]), ]) ->afterValidation(function () { if (! $this->isConnected) { Notification::make() ->title(__('Connection required')) ->body(__('Please test the connection first')) ->danger() ->send(); throw new Exception(__('Please test the connection first')); } $this->step1Complete = true; $this->saveToSession(); }); } protected function getSelectAccountsStep(): Step { return Step::make(__('Select Accounts')) ->id('accounts') ->icon('heroicon-o-users') ->description(__('Choose which accounts to migrate')) ->schema([ Section::make(__('cPanel Accounts')) ->description(fn () => ! empty($this->selectedAccounts) ? __(':selected of :count accounts selected', ['selected' => count($this->selectedAccounts), 'count' => count($this->accounts)]) : __(':count accounts found on server', ['count' => count($this->accounts)])) ->icon('heroicon-o-user-group') ->headerActions([ Action::make('refreshAccounts') ->label(__('Refresh')) ->icon('heroicon-o-arrow-path') ->color('gray') ->action('refreshAccounts'), Action::make('selectAll') ->label(__('Select All')) ->icon('heroicon-o-check') ->color('primary') ->action('selectAllAccounts') ->visible(fn () => count($this->selectedAccounts) < count($this->accounts)), Action::make('deselectAll') ->label(__('Deselect All')) ->icon('heroicon-o-x-mark') ->color('gray') ->action('deselectAllAccounts') ->visible(fn () => count($this->selectedAccounts) > 0), ]) ->schema([ View::make('filament.admin.pages.whm-accounts-table'), ]), ]) ->afterValidation(function () { if (empty($this->selectedAccounts)) { Notification::make() ->title(__('No accounts selected')) ->body(__('Please select at least one account to migrate')) ->danger() ->send(); throw new Exception(__('Please select at least one account')); } // Initialize account configuration for selected accounts $this->initializeAccountConfig(); $this->step2Complete = true; $this->saveToSession(); // Notify the config table widget to refresh $this->dispatch('whm-config-updated'); }); } protected function getConfigureStep(): Step { return Step::make(__('Configure')) ->id('configure') ->icon('heroicon-o-cog') ->description(__('Configure migration options')) ->schema([ Section::make(__('Global Options')) ->description(__('These options apply to all selected accounts')) ->icon('heroicon-o-adjustments-horizontal') ->schema([ Grid::make(['default' => 1, 'sm' => 2])->schema([ Checkbox::make('createLinuxUsers') ->label(__('Create Linux system users')) ->helperText(__('Creates Linux user accounts on this server')) ->default(true), Checkbox::make('sendWelcomeEmail') ->label(__('Send welcome email to users')) ->helperText(__('Notify users after their account is migrated')) ->default(false), ]), ]), Section::make(__('What to Restore')) ->description(__('Select which parts of each account to restore')) ->icon('heroicon-o-check-circle') ->schema([ Grid::make(['default' => 1, 'sm' => 2])->schema([ Checkbox::make('restoreFiles') ->label(__('Website Files')) ->helperText(__('Restore all website files')) ->default(true), Checkbox::make('restoreDatabases') ->label(__('MySQL Databases')) ->helperText(__('Restore databases with all data')) ->default(true), Checkbox::make('restoreEmails') ->label(__('Email Mailboxes')) ->helperText(__('Restore email accounts and messages')) ->default(true), Checkbox::make('restoreSsl') ->label(__('SSL Certificates')) ->helperText(__('Restore SSL certificates for domains')) ->default(true), ]), ]), Section::make(__('Account Mappings')) ->description(fn () => __(':count accounts to migrate', ['count' => count($this->selectedAccounts)])) ->icon('heroicon-o-arrow-right') ->schema([ View::make('filament.admin.pages.whm-account-config-table'), ]), ]); } protected function getMigrateStep(): Step { return Step::make(__('Migrate')) ->id('migrate') ->icon('heroicon-o-play') ->description(__('Migration progress')) ->schema([ FormActions::make([ Action::make('startMigration') ->label(__('Start Migration')) ->icon('heroicon-o-play') ->color('success') ->visible(fn () => ! $this->isMigrating && empty($this->migrationStatus)) ->requiresConfirmation() ->modalHeading(__('Start Migration')) ->modalDescription(__('This will migrate :count account(s). Existing data may be overwritten. Continue?', ['count' => count($this->selectedAccounts)])) ->action('startMigration'), Action::make('newMigration') ->label(__('New Migration')) ->icon('heroicon-o-plus') ->color('primary') ->visible(fn () => ! $this->isMigrating && ! empty($this->migrationStatus)) ->action('resetMigration'), ])->alignEnd(), Section::make(__('Overall Progress')) ->icon($this->isMigrating ? 'heroicon-o-arrow-path' : ($this->getMigrationCompletedCount() === count($this->selectedAccounts) && ! empty($this->selectedAccounts) ? 'heroicon-o-check-circle' : 'heroicon-o-clock')) ->iconColor($this->isMigrating ? 'warning' : ($this->getMigrationCompletedCount() === count($this->selectedAccounts) && ! empty($this->selectedAccounts) ? 'success' : 'gray')) ->schema($this->getOverallProgressSchema()) ->extraAttributes($this->isMigrating ? ['wire:poll.5s' => 'pollMigrationStatus'] : []), Section::make(__('Account Status')) ->icon('heroicon-o-queue-list') ->schema([ View::make('filament.admin.pages.whm-migration-status-table'), ]) ->visible(fn () => ! empty($this->migrationStatus)), ]); } protected function getOverallProgressSchema(): array { if (empty($this->selectedAccounts)) { return [ Text::make(__('No accounts selected for migration.'))->color('gray'), ]; } if (empty($this->migrationStatus)) { return [ Text::make(__('Click "Start Migration" to begin.'))->color('gray'), Text::make(__(':count account(s) will be migrated.', ['count' => count($this->selectedAccounts)]))->color('primary'), ]; } $total = count($this->selectedAccounts); $completed = $this->getMigrationCompletedCount(); $errors = $this->getMigrationErrorCount(); $percent = $total > 0 ? round(($completed / $total) * 100) : 0; return [ Grid::make(['default' => 2, 'sm' => 4])->schema([ Section::make((string) $total) ->description(__('Total')) ->icon('heroicon-o-users') ->iconColor('primary') ->compact(), Section::make((string) $completed) ->description(__('Completed')) ->icon('heroicon-o-check-circle') ->iconColor('success') ->compact(), Section::make((string) $errors) ->description(__('Errors')) ->icon('heroicon-o-x-circle') ->iconColor($errors > 0 ? 'danger' : 'gray') ->compact(), Section::make("{$percent}%") ->description(__('Progress')) ->icon('heroicon-o-chart-pie') ->iconColor('info') ->compact(), ]), ]; } public function table(Table $table): Table { return $table ->records(fn () => $this->getAccountConfigRecords()) ->columns([ Tables\Columns\IconColumn::make('exists') ->label(__('Status')) ->boolean() ->trueIcon('heroicon-o-exclamation-triangle') ->falseIcon('heroicon-o-user-plus') ->trueColor('warning') ->falseColor('success') ->tooltip(fn (array $record): string => $record['exists'] ? __('User exists - will restore to existing account') : __('New user will be created')), Tables\Columns\TextColumn::make('user') ->label(__('Username')) ->weight('bold') ->searchable(), Tables\Columns\TextColumn::make('domain') ->label(__('Domain')) ->searchable(), Tables\Columns\TextColumn::make('email') ->label(__('Email')) ->icon('heroicon-o-envelope'), Tables\Columns\TextColumn::make('diskused') ->label(__('Size')), ]) ->striped() ->paginated([10, 25, 50]) ->defaultPaginationPageOption(10) ->emptyStateHeading(__('No accounts selected')) ->emptyStateDescription(__('Go back to Step 2 and select accounts to migrate')) ->emptyStateIcon('heroicon-o-user-group'); } protected function getAccountConfigRecords(): array { $records = []; foreach ($this->selectedAccounts as $cpanelUser) { $account = collect($this->accounts)->firstWhere('user', $cpanelUser); $config = $this->accountConfig[$cpanelUser] ?? []; $domain = $account['domain'] ?? ''; $email = $config['email'] ?? $account['email'] ?? "{$cpanelUser}@{$domain}"; $existingUser = User::where('username', $cpanelUser)->first(); $records[] = [ 'user' => $cpanelUser, 'domain' => $domain, 'email' => $email, 'exists' => $existingUser !== null, 'diskused' => $account['diskused'] ?? '', ]; } return $records; } public function testConnection(): void { if (empty($this->hostname) || empty($this->whmUsername) || empty($this->apiToken)) { Notification::make() ->title(__('Missing credentials')) ->body(__('Please fill in all required fields')) ->danger() ->send(); return; } try { $whm = $this->getWhm(); $result = $whm->testConnection(); if ($result['success']) { // Get account list $accountsResult = $whm->listAccounts(); if ($accountsResult['success']) { $this->accounts = $accountsResult['accounts']; } $this->isConnected = true; $this->serverInfo = [ 'version' => $result['version'] ?? 'Unknown', 'account_count' => count($this->accounts), ]; $this->saveToSession(); $this->dispatch('whm-accounts-updated'); Notification::make() ->title(__('Connection successful')) ->body(__('Connected to WHM server. Found :count cPanel accounts.', [ 'count' => count($this->accounts), ])) ->success() ->send(); } else { throw new Exception($result['message'] ?? __('Connection failed')); } } catch (Exception $e) { $this->isConnected = false; Log::error('WHM connection failed', ['error' => $e->getMessage()]); Notification::make() ->title(__('Connection failed')) ->body($e->getMessage()) ->danger() ->send(); } } public function refreshAccounts(): void { $whm = $this->getWhm(); if (! $whm) { return; } try { $result = $whm->listAccounts(); if ($result['success']) { $this->accounts = $result['accounts']; $this->serverInfo['account_count'] = count($this->accounts); $this->saveToSession(); $this->dispatch('whm-accounts-updated'); Notification::make() ->title(__('Accounts refreshed')) ->body(__('Found :count cPanel accounts.', ['count' => count($this->accounts)])) ->success() ->send(); } } catch (Exception $e) { Notification::make() ->title(__('Refresh failed')) ->body($e->getMessage()) ->danger() ->send(); } } public function toggleAccountSelection(string $user): void { if (in_array($user, $this->selectedAccounts)) { $this->selectedAccounts = array_values(array_diff($this->selectedAccounts, [$user])); } else { $this->selectedAccounts[] = $user; } $this->saveToSession(); } #[On('whm-selection-updated')] public function handleSelectionUpdated(array $selectedAccounts): void { $this->selectedAccounts = $selectedAccounts; $this->saveToSession(); } public function selectAllAccounts(): void { $this->selectedAccounts = array_map(fn ($a) => $a['user'], $this->accounts); $this->saveToSession(); $this->dispatch('whm-accounts-updated'); } public function deselectAllAccounts(): void { $this->selectedAccounts = []; $this->saveToSession(); $this->dispatch('whm-accounts-updated'); } protected function initializeAccountConfig(): void { foreach ($this->selectedAccounts as $cpanelUser) { if (isset($this->accountConfig[$cpanelUser])) { continue; } $account = collect($this->accounts)->firstWhere('user', $cpanelUser); $domain = $account['domain'] ?? ''; $email = $account['email'] ?? "{$cpanelUser}@{$domain}"; $this->accountConfig[$cpanelUser] = [ 'jabali_username' => $cpanelUser, 'email' => $email, 'password' => bin2hex(random_bytes(8)), // Generate random password ]; } } public function startMigration(): void { if (empty($this->selectedAccounts)) { return; } $this->isMigrating = true; $this->currentAccountIndex = 0; $this->totalAccounts = count($this->selectedAccounts); $store = $this->getMigrationStatusStore(); $state = $store->initialize($this->selectedAccounts); $this->migrationStatus = $state['migrationStatus'] ?? []; $this->saveToSession(); RunWhmMigrationBatch::dispatch( cacheKey: $this->getMigrationCacheKey(), hostname: $this->hostname ?? '', whmUsername: $this->whmUsername ?? 'root', apiToken: $this->apiToken ?? '', port: $this->port, useSSL: $this->useSSL, accounts: $this->accounts, selectedAccounts: $this->selectedAccounts, restoreFiles: $this->restoreFiles, restoreDatabases: $this->restoreDatabases, restoreEmails: $this->restoreEmails, restoreSsl: $this->restoreSsl, createLinuxUsers: $this->createLinuxUsers, ); } #[\Livewire\Attributes\On('process-next-account')] public function handleProcessNextAccount(): void { $this->processNextAccount(); } public function processNextAccount(): void { if ($this->currentAccountIndex >= $this->totalAccounts) { $this->isMigrating = false; $this->saveToSession(); $completed = $this->getMigrationCompletedCount(); $errors = $this->getMigrationErrorCount(); Notification::make() ->title(__('Migration complete')) ->body(__(':completed of :total accounts migrated successfully. :errors errors.', [ 'completed' => $completed, 'total' => $this->totalAccounts, 'errors' => $errors, ])) ->success() ->send(); $this->resetMigration(); return; } $cpanelUser = $this->selectedAccounts[$this->currentAccountIndex]; $this->migrateAccount($cpanelUser); } protected function migrateAccount(string $cpanelUser): void { $this->updateAccountStatus($cpanelUser, 'processing', __('Starting migration...')); try { $whm = $this->getWhm(); if (! $whm) { throw new Exception(__('WHM connection lost')); } $account = collect($this->accounts)->firstWhere('user', $cpanelUser); $domain = $account['domain'] ?? ''; $email = $account['email'] ?? "{$cpanelUser}@{$domain}"; // Step 1: Create or get Jabali user $user = $this->createOrGetUser($cpanelUser, $email); if (! $user) { throw new Exception(__('Failed to create user')); } $this->addAccountLog($cpanelUser, __('User ready: :username', ['username' => $user->username]), 'success'); // Step 2: Set up SSH key for SCP transfer (same as cPanel migration) $this->updateAccountStatus($cpanelUser, 'backup_creating', __('Setting up backup transfer...')); $keyName = $this->getSshKeyName(); $destPath = $this->getBackupDestPath(); // Ensure destination directory exists if (! is_dir($destPath)) { mkdir($destPath, 0755, true); } // Ensure Jabali SSH key exists (via agent which runs as root) $this->getAgent()->send('jabali_ssh.ensure_exists', []); // Get Jabali's public key and add to authorized_keys $publicKeyResult = $this->getAgent()->send('jabali_ssh.get_public_key', []); if (! ($publicKeyResult['success'] ?? false) || ! ($publicKeyResult['exists'] ?? false)) { throw new Exception(__('Failed to get Jabali public key')); } $publicKey = $publicKeyResult['public_key'] ?? null; // Add to authorized_keys so cPanel can SCP to us $this->getAgent()->send('jabali_ssh.add_to_authorized_keys', [ 'public_key' => $publicKey, 'comment' => 'whm-migration-'.$cpanelUser, ]); // Read Jabali's SSH private key via agent (runs as root) $privateKeyResult = $this->getAgent()->send('jabali_ssh.get_private_key', []); if (! ($privateKeyResult['success'] ?? false) || ! ($privateKeyResult['exists'] ?? false)) { throw new Exception(__('Failed to read Jabali private key')); } $privateKey = $privateKeyResult['private_key'] ?? null; if (empty($privateKey)) { throw new Exception(__('Private key is empty')); } $this->addAccountLog($cpanelUser, __('Importing SSH key to cPanel...'), 'pending'); // Import SSH key to the cPanel user via WHM $importResult = $whm->importSshPrivateKey($cpanelUser, $keyName, $privateKey); if (! ($importResult['success'] ?? false)) { throw new Exception($importResult['message'] ?? __('Failed to import SSH key')); } // Use actual key name if it was different (key already existed under different name) $actualKeyName = $importResult['actual_key_name'] ?? $keyName; $this->addAccountLog($cpanelUser, __('SSH key imported'), 'success'); // Authorize the key $authResult = $whm->authorizeSshKey($cpanelUser, $actualKeyName); if (! ($authResult['success'] ?? false)) { $this->addAccountLog($cpanelUser, __('SSH key authorization skipped'), 'info'); } else { $this->addAccountLog($cpanelUser, __('SSH key authorized'), 'success'); } // Step 3: Initiate backup with SCP transfer to Jabali $this->addAccountLog($cpanelUser, __('Initiating backup transfer...'), 'pending'); $jabaliIp = $this->getJabaliPublicIp(); $backupResult = $whm->createBackupToScpWithKey( $cpanelUser, $jabaliIp, 'root', $destPath, $actualKeyName, 22 ); if (! ($backupResult['success'] ?? false)) { throw new Exception($backupResult['message'] ?? __('Failed to start backup')); } $this->addAccountLog($cpanelUser, __('Backup initiated, transferring via SCP...'), 'success'); // Step 4: Wait for backup to arrive $this->updateAccountStatus($cpanelUser, 'backup_downloading', __('Waiting for backup file...')); $backupPath = $this->waitForBackupFile($cpanelUser, $destPath); if (! $backupPath) { throw new Exception(__('Backup file did not arrive')); } $this->addAccountLog($cpanelUser, __('Backup received: :size', ['size' => $this->formatBytes(filesize($backupPath))]), 'success'); // Step 5: Get migration summary for this user $summary = $whm->getUserMigrationSummary($cpanelUser); $discoveredData = $whm->convertApiDataToAgentFormat($summary); // Step 6: Restore backup $this->updateAccountStatus($cpanelUser, 'restoring', __('Restoring data...')); $result = $this->getAgent()->send('cpanel.restore_backup', [ 'backup_path' => $backupPath, 'username' => $user->username, 'restore_files' => $this->restoreFiles, 'restore_databases' => $this->restoreDatabases, 'restore_emails' => $this->restoreEmails, 'restore_ssl' => $this->restoreSsl, 'discovered_data' => $discoveredData, ]); if ($result['success'] ?? false) { foreach ($result['log'] ?? [] as $entry) { $this->addAccountLog($cpanelUser, $entry['message'], $entry['status'] ?? 'info'); } $this->updateAccountStatus($cpanelUser, 'completed', __('Migration completed')); // Clean up backup file @unlink($backupPath); } else { throw new Exception($result['error'] ?? __('Restore failed')); } } catch (Exception $e) { Log::error('WHM migration failed for user', ['user' => $cpanelUser, 'error' => $e->getMessage()]); $this->updateAccountStatus($cpanelUser, 'error', $e->getMessage()); } // Move to next account $this->currentAccountIndex++; $this->saveToSession(); // Process next account - dispatch to allow UI update $this->dispatch('process-next-account'); } protected function createOrGetUser(string $cpanelUser, string $email): ?User { // Check if user already exists $existingUser = User::where('username', $cpanelUser)->first(); if ($existingUser) { return $existingUser; } // Check if email already exists if (User::where('email', $email)->exists()) { $email = "{$cpanelUser}.".time().'@'.explode('@', $email)[1]; } $password = bin2hex(random_bytes(12)); try { if ($this->createLinuxUsers) { // Check if Linux user already exists exec('id '.escapeshellarg($cpanelUser).' 2>/dev/null', $output, $exitCode); if ($exitCode !== 0) { $result = $this->getAgent()->send('user.create', [ 'username' => $cpanelUser, 'password' => $password, ]); if (! ($result['success'] ?? false)) { throw new Exception($result['error'] ?? __('Failed to create system user')); } } } // Create panel user record $user = User::create([ 'name' => ucfirst($cpanelUser), 'username' => $cpanelUser, 'email' => $email, 'password' => Hash::make($password), 'home_directory' => '/home/'.$cpanelUser, 'disk_quota_mb' => null, 'is_active' => true, 'is_admin' => false, ]); return $user; } catch (Exception $e) { Log::error('Failed to create user', ['username' => $cpanelUser, 'error' => $e->getMessage()]); return null; } } /** * Wait for backup file to arrive via SCP transfer */ protected function waitForBackupFile(string $cpanelUser, string $destPath): ?string { $maxAttempts = 120; // 10 minutes (5s interval) $attempt = 0; $lastSeenSize = 0; $sizeStableCount = 0; while ($attempt < $maxAttempts) { $attempt++; sleep(5); // Look for backup files matching this user $pattern = "{$destPath}/backup-*_{$cpanelUser}.tar.gz"; $files = glob($pattern); // Also check for cpmove format if (empty($files)) { $pattern = "{$destPath}/cpmove-{$cpanelUser}.tar.gz"; $files = glob($pattern); } if (empty($files)) { if ($attempt % 6 === 0) { // Log every 30 seconds $this->addAccountLog($cpanelUser, __('Waiting for backup file... (:count s)', ['count' => $attempt * 5]), 'pending'); } continue; } // Sort by modification time, get newest usort($files, fn ($a, $b) => filemtime($b) - filemtime($a)); $backupFile = $files[0]; $currentSize = filesize($backupFile); // Check if size is stable (transfer finished) if ($currentSize > 0 && $currentSize === $lastSeenSize) { $sizeStableCount++; } else { $sizeStableCount = 0; } $lastSeenSize = $currentSize; // Require size to be stable for 3 checks (15 seconds) and at least 10KB if ($sizeStableCount >= 3 && $currentSize >= 10 * 1024) { // Fix permissions - file arrives as root via SCP $this->getAgent()->send('file.chown', [ 'path' => $backupFile, 'owner' => 'www-data', 'group' => 'www-data', ]); // Verify it's a valid gzip $handle = fopen($backupFile, 'rb'); $magic = $handle ? fread($handle, 2) : ''; if ($handle) { fclose($handle); } if ($magic === "\x1f\x8b") { return $backupFile; } else { $this->addAccountLog($cpanelUser, __('Invalid backup file format, waiting...'), 'warning'); $sizeStableCount = 0; } } if ($attempt % 6 === 0) { // Log every 30 seconds $this->addAccountLog($cpanelUser, __('Receiving backup... :size', [ 'size' => $this->formatBytes($currentSize), ]), 'pending'); } } return null; } protected function updateAccountStatus(string $user, string $status, string $message): void { $this->migrationStatus[$user]['status'] = $status; $this->addAccountLog($user, $message, $status === 'error' ? 'error' : 'info'); $this->saveToSession(); $this->dispatch('whm-migration-status-updated'); } protected function addAccountLog(string $user, string $message, string $status = 'info'): void { $this->migrationStatus[$user]['log'][] = [ 'message' => $message, 'status' => $status, 'time' => now()->format('H:i:s'), ]; $this->saveToSession(); } public function pollMigrationStatus(): void { $this->loadMigrationStatusFromStore(); $this->dispatch('whm-migration-status-updated'); } protected function getMigrationCompletedCount(): int { return count(array_filter($this->migrationStatus, fn ($s) => $s['status'] === 'completed')); } protected function getMigrationErrorCount(): int { return count(array_filter($this->migrationStatus, fn ($s) => $s['status'] === 'error')); } public function resetMigration(): void { $this->wizardStep = null; $this->hostname = null; $this->whmUsername = 'root'; $this->apiToken = null; $this->port = 2087; $this->useSSL = true; $this->isConnected = false; $this->serverInfo = []; $this->accounts = []; $this->selectedAccounts = []; $this->accountConfig = []; $this->step1Complete = false; $this->step2Complete = false; $this->isMigrating = false; $this->migrationStatus = []; $this->currentAccountIndex = 0; $this->totalAccounts = 0; $this->statusLog = []; $this->whm = null; $this->getMigrationStatusStore()->clear(); $this->clearSession(); $this->redirect(static::getUrl()); } protected function formatBytes(int $bytes, int $precision = 2): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, $precision).' '.$units[$pow]; } }