1268 lines
47 KiB
PHP
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];
|
|
}
|
|
}
|