Files
jabali-panel/app/Filament/Admin/Pages/WhmMigration.php
2026-02-02 03:11:45 +02:00

1268 lines
47 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Jobs\RunWhmMigrationBatch;
use App\Models\User;
use App\Services\Agent\AgentClient;
use App\Services\Migration\WhmApiService;
use App\Services\Migration\WhmMigrationStatusStore;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Concerns\InteractsWithInfolists;
use Filament\Infolists\Contracts\HasInfolists;
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\Text;
use Filament\Schemas\Components\View;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
class WhmMigration extends Page implements HasActions, HasForms, HasInfolists, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithInfolists;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-server-stack';
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
public static function getNavigationLabel(): string
{
return __('WHM Migration');
}
protected static ?int $navigationSort = 22;
public static function getNavigationGroup(): string
{
return __('Server');
}
protected string $view = 'filament.admin.pages.whm-migration';
// Track wizard step via URL
#[Url(as: 'whm-step')]
public ?string $wizardStep = null;
// Track step completion
public bool $step1Complete = false;
public bool $step2Complete = false;
// Connection form (Step 1)
public ?string $hostname = null;
public ?string $whmUsername = 'root';
public ?string $apiToken = null;
public int $port = 2087;
public bool $useSSL = true;
// Connection status
public bool $isConnected = false;
public array $serverInfo = [];
// Account list from WHM (Step 2)
public array $accounts = [];
public array $selectedAccounts = [];
// Migration configuration (Step 3)
public bool $createLinuxUsers = true;
public bool $sendWelcomeEmail = false;
public bool $restoreFiles = true;
public bool $restoreDatabases = true;
public bool $restoreEmails = true;
public bool $restoreSsl = true;
// Per-account configuration
public array $accountConfig = []; // user => ['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];
}
}