From e7920366d7ab83562618001ce70d3c3b0e1d90a1 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 10 Feb 2026 23:51:34 +0200 Subject: [PATCH] Add DirectAdmin migration UI (Phase 1) --- .../Admin/Pages/DirectAdminMigration.php | 732 ++++++++++++++++++ app/Filament/Admin/Pages/Migration.php | 11 +- .../Widgets/DirectAdminAccountConfigTable.php | 142 ++++ .../Widgets/DirectAdminAccountsTable.php | 155 ++++ .../DirectAdminMigrationStatusTable.php | 186 +++++ .../Jabali/Pages/DirectAdminMigration.php | 615 +++++++++++++++ .../DirectAdminMigrationStatusTable.php | 170 ++++ bin/jabali-agent | 83 +- ...directadmin-account-config-table.blade.php | 4 + .../directadmin-accounts-table.blade.php | 4 + ...rectadmin-migration-status-table.blade.php | 4 + .../pages/directadmin-migration.blade.php | 6 + .../pages/migration-directadmin-tab.blade.php | 2 + ...rectadmin-migration-status-table.blade.php | 4 + .../pages/directadmin-migration.blade.php | 6 + 15 files changed, 2095 insertions(+), 29 deletions(-) create mode 100644 app/Filament/Admin/Pages/DirectAdminMigration.php create mode 100644 app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php create mode 100644 app/Filament/Admin/Widgets/DirectAdminAccountsTable.php create mode 100644 app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php create mode 100644 app/Filament/Jabali/Pages/DirectAdminMigration.php create mode 100644 app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php create mode 100644 resources/views/filament/admin/pages/directadmin-account-config-table.blade.php create mode 100644 resources/views/filament/admin/pages/directadmin-accounts-table.blade.php create mode 100644 resources/views/filament/admin/pages/directadmin-migration-status-table.blade.php create mode 100644 resources/views/filament/admin/pages/directadmin-migration.blade.php create mode 100644 resources/views/filament/admin/pages/migration-directadmin-tab.blade.php create mode 100644 resources/views/filament/jabali/pages/directadmin-migration-status-table.blade.php create mode 100644 resources/views/filament/jabali/pages/directadmin-migration.blade.php diff --git a/app/Filament/Admin/Pages/DirectAdminMigration.php b/app/Filament/Admin/Pages/DirectAdminMigration.php new file mode 100644 index 0000000..268ffb5 --- /dev/null +++ b/app/Filament/Admin/Pages/DirectAdminMigration.php @@ -0,0 +1,732 @@ +label(__('Start Over')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('Start Over')) + ->modalDescription(__('This will reset the DirectAdmin migration wizard. Are you sure?')) + ->action('resetMigration'), + ]; + } + + public function mount(): void + { + $this->restoreFromSession(); + $this->restoreFromImport(); + } + + 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(), + ]) + ->persistStepInQueryString('directadmin-step'), + ]); + } + + protected function getConnectStep(): Step + { + return Step::make(__('Connect')) + ->id('connect') + ->icon('heroicon-o-link') + ->description(__('Connect to DirectAdmin or upload a backup')) + ->schema([ + Section::make(__('Source')) + ->description(__('Choose how you want to migrate DirectAdmin accounts.')) + ->icon('heroicon-o-server') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + TextInput::make('name') + ->label(__('Import Name')) + ->default(fn (): string => $this->name ?: ('DirectAdmin Import ' . now()->format('Y-m-d H:i'))) + ->maxLength(255) + ->required(), + Radio::make('importMethod') + ->label(__('Import Method')) + ->options([ + 'remote_server' => __('Remote Server'), + 'backup_file' => __('Backup File'), + ]) + ->default('remote_server') + ->live(), + ]), + + Grid::make(['default' => 1, 'sm' => 2]) + ->schema([ + TextInput::make('remoteHost') + ->label(__('Host')) + ->placeholder('directadmin.example.com') + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + TextInput::make('remotePort') + ->label(__('Port')) + ->numeric() + ->default(2222) + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + TextInput::make('remoteUser') + ->label(__('Username')) + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + TextInput::make('remotePassword') + ->label(__('Password')) + ->password() + ->revealable() + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + ]), + + FileUpload::make('backupPath') + ->label(__('DirectAdmin Backup Archive')) + ->helperText(__('Upload a .tar.gz DirectAdmin backup file.')) + ->disk('local') + ->directory('imports/directadmin') + ->preserveFilenames() + ->acceptedFileTypes([ + 'application/gzip', + 'application/x-gzip', + 'application/x-tar', + 'application/octet-stream', + ]) + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'), + + FormActions::make([ + Action::make('discoverAccounts') + ->label(__('Discover Accounts')) + ->icon('heroicon-o-magnifying-glass') + ->color('primary') + ->action('discoverAccounts'), + ])->alignEnd(), + ]), + + Section::make(__('Discovery')) + ->description(__('Once accounts are discovered, proceed to select which ones to import.')) + ->icon('heroicon-o-user-group') + ->schema([ + Text::make(__('Discovered accounts will appear in the next step.'))->color('gray'), + ]), + ]) + ->afterValidation(function () { + $import = $this->getImport(); + $hasAccounts = $import?->accounts()->exists() ?? false; + + if (! $hasAccounts) { + Notification::make() + ->title(__('No accounts discovered')) + ->body(__('Click "Discover Accounts" to continue.')) + ->danger() + ->send(); + + throw new Exception(__('No accounts discovered')); + } + + $this->step1Complete = true; + $this->saveToSession(); + }); + } + + protected function getSelectAccountsStep(): Step + { + return Step::make(__('Select Accounts')) + ->id('accounts') + ->icon('heroicon-o-users') + ->description(__('Choose which DirectAdmin accounts to migrate')) + ->schema([ + Section::make(__('DirectAdmin Accounts')) + ->description(fn (): string => $this->getAccountsStepDescription()) + ->icon('heroicon-o-user-group') + ->headerActions([ + Action::make('refreshAccounts') + ->label(__('Refresh')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action('refreshAccountsTable'), + Action::make('selectAll') + ->label(__('Select All')) + ->icon('heroicon-o-check') + ->color('primary') + ->action('selectAllAccounts') + ->visible(fn (): bool => $this->getSelectedAccountsCount() < $this->getDiscoveredAccountsCount()), + Action::make('deselectAll') + ->label(__('Deselect All')) + ->icon('heroicon-o-x-mark') + ->color('gray') + ->action('deselectAllAccounts') + ->visible(fn (): bool => $this->getSelectedAccountsCount() > 0), + ]) + ->schema([ + View::make('filament.admin.pages.directadmin-accounts-table'), + ]), + ]) + ->afterValidation(function () { + if ($this->getSelectedAccountsCount() === 0) { + Notification::make() + ->title(__('No accounts selected')) + ->body(__('Please select at least one account to migrate.')) + ->danger() + ->send(); + + throw new Exception(__('No accounts selected')); + } + + $this->saveToSession(); + }); + } + + protected function getConfigureStep(): Step + { + return Step::make(__('Configure')) + ->id('configure') + ->icon('heroicon-o-cog') + ->description(__('Choose what to import and map accounts')) + ->schema([ + Section::make(__('What to Import')) + ->description(__('Select which parts of each account to import.')) + ->icon('heroicon-o-check-circle') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + Checkbox::make('importFiles') + ->label(__('Website Files')) + ->helperText(__('Restore website files from the backup')) + ->default(true), + Checkbox::make('importDatabases') + ->label(__('Databases')) + ->helperText(__('Restore MySQL databases and import dumps')) + ->default(true), + Checkbox::make('importEmails') + ->label(__('Email')) + ->helperText(__('Create email domains and mailboxes (limited in Phase 1)')) + ->default(true), + Checkbox::make('importSsl') + ->label(__('SSL')) + ->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)')) + ->default(true), + ]), + ]), + + Section::make(__('Account Mappings')) + ->description(fn (): string => __(':count account(s) selected', ['count' => $this->getSelectedAccountsCount()])) + ->icon('heroicon-o-arrow-right') + ->schema([ + View::make('filament.admin.pages.directadmin-account-config-table'), + ]), + ]) + ->afterValidation(function (): void { + $import = $this->getImport(); + if (! $import) { + throw new Exception(__('Import job not found')); + } + + $import->update([ + 'import_options' => [ + 'files' => $this->importFiles, + 'databases' => $this->importDatabases, + 'emails' => $this->importEmails, + 'ssl' => $this->importSsl, + ], + ]); + + $this->saveToSession(); + $this->dispatch('directadmin-config-updated'); + }); + } + + protected function getMigrateStep(): Step + { + return Step::make(__('Migrate')) + ->id('migrate') + ->icon('heroicon-o-play') + ->description(__('Run the migration and watch progress')) + ->schema([ + FormActions::make([ + Action::make('startMigration') + ->label(__('Start Migration')) + ->icon('heroicon-o-play') + ->color('success') + ->requiresConfirmation() + ->modalHeading(__('Start Migration')) + ->modalDescription(__('This will migrate :count account(s). Continue?', ['count' => $this->getSelectedAccountsCount()])) + ->action('startMigration'), + + Action::make('newMigration') + ->label(__('New Migration')) + ->icon('heroicon-o-plus') + ->color('primary') + ->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed') + ->action('resetMigration'), + ])->alignEnd(), + + Section::make(__('Import Status')) + ->icon('heroicon-o-queue-list') + ->schema([ + View::make('filament.admin.pages.directadmin-migration-status-table'), + ]), + ]); + } + + public function discoverAccounts(): void + { + try { + $import = $this->upsertImportForDiscovery(); + + $backupFullPath = null; + $remotePassword = null; + + if ($this->importMethod === 'backup_file') { + if (! $import->backup_path) { + throw new Exception(__('Please upload a DirectAdmin backup archive.')); + } + + $backupFullPath = Storage::disk('local')->path($import->backup_path); + } else { + $remotePassword = $this->remotePassword; + + if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) { + $remotePassword = (string) $import->remote_password; + } + + if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) { + throw new Exception(__('Please enter DirectAdmin host, port, username and password.')); + } + } + + $result = $this->getAgent()->importDiscover( + $import->id, + 'directadmin', + $import->import_method, + $backupFullPath, + $import->remote_host, + $import->remote_port ? (int) $import->remote_port : null, + $import->remote_user, + $remotePassword, + ); + + if (! ($result['success'] ?? false)) { + throw new Exception((string) ($result['error'] ?? __('Discovery failed'))); + } + + $accounts = $result['accounts'] ?? []; + if (! is_array($accounts) || $accounts === []) { + throw new Exception(__('No accounts were discovered.')); + } + + $import->accounts()->delete(); + $createdIds = []; + + foreach ($accounts as $account) { + if (! is_array($account)) { + continue; + } + + $username = trim((string) ($account['username'] ?? '')); + if ($username === '') { + continue; + } + + $record = ServerImportAccount::create([ + 'server_import_id' => $import->id, + 'source_username' => $username, + 'target_username' => $username, + 'email' => (string) ($account['email'] ?? ''), + 'main_domain' => (string) ($account['main_domain'] ?? ''), + 'addon_domains' => $account['addon_domains'] ?? [], + 'subdomains' => $account['subdomains'] ?? [], + 'databases' => $account['databases'] ?? [], + 'email_accounts' => $account['email_accounts'] ?? [], + 'disk_usage' => (int) ($account['disk_usage'] ?? 0), + 'status' => 'pending', + 'progress' => 0, + 'current_task' => null, + 'import_log' => [], + 'error' => null, + ]); + + $createdIds[] = $record->id; + } + + if ($createdIds === []) { + throw new Exception(__('No valid accounts were discovered.')); + } + + $import->update([ + 'discovered_accounts' => $accounts, + 'selected_accounts' => [], + 'status' => 'ready', + 'progress' => 0, + 'current_task' => null, + 'errors' => [], + ]); + + $this->importId = $import->id; + $this->step1Complete = true; + $this->saveToSession(); + + $this->dispatch('directadmin-accounts-updated'); + + Notification::make() + ->title(__('Accounts discovered')) + ->body(__('Found :count account(s).', ['count' => count($createdIds)])) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Discovery failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function selectAllAccounts(): void + { + $import = $this->getImport(); + if (! $import) { + return; + } + + $ids = $import->accounts()->pluck('id')->all(); + $import->update(['selected_accounts' => $ids]); + + $this->dispatch('directadmin-selection-updated'); + } + + public function deselectAllAccounts(): void + { + $import = $this->getImport(); + if (! $import) { + return; + } + + $import->update(['selected_accounts' => []]); + + $this->dispatch('directadmin-selection-updated'); + } + + public function refreshAccountsTable(): void + { + $this->dispatch('directadmin-accounts-updated'); + $this->dispatch('directadmin-config-updated'); + } + + public function startMigration(): void + { + $import = $this->getImport(); + if (! $import) { + Notification::make() + ->title(__('Import job not found')) + ->danger() + ->send(); + return; + } + + $selected = $import->selected_accounts ?? []; + if (! is_array($selected) || $selected === []) { + Notification::make() + ->title(__('No accounts selected')) + ->body(__('Please select at least one account to migrate.')) + ->danger() + ->send(); + return; + } + + if ($import->import_method === 'remote_server') { + Notification::make() + ->title(__('Remote DirectAdmin import is not available yet')) + ->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.')) + ->warning() + ->send(); + return; + } + + $import->update([ + 'status' => 'importing', + 'started_at' => now(), + ]); + + $result = $this->getAgent()->importStart($import->id); + + if (! ($result['success'] ?? false)) { + Notification::make() + ->title(__('Failed to start migration')) + ->body((string) ($result['error'] ?? __('Unknown error'))) + ->danger() + ->send(); + return; + } + + Notification::make() + ->title(__('Migration started')) + ->body(__('Import process has started in the background.')) + ->success() + ->send(); + } + + public function resetMigration(): void + { + if ($this->importId) { + ServerImport::whereKey($this->importId)->delete(); + } + + session()->forget('directadmin_migration.import_id'); + + $this->wizardStep = null; + $this->step1Complete = false; + $this->importId = null; + $this->name = null; + $this->importMethod = 'remote_server'; + $this->remoteHost = null; + $this->remotePort = 2222; + $this->remoteUser = null; + $this->remotePassword = null; + $this->backupPath = null; + $this->importFiles = true; + $this->importDatabases = true; + $this->importEmails = true; + $this->importSsl = true; + } + + protected function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + protected function getImport(): ?ServerImport + { + if (! $this->importId) { + return null; + } + + return ServerImport::with('accounts')->find($this->importId); + } + + protected function upsertImportForDiscovery(): ServerImport + { + $name = trim((string) ($this->name ?: '')); + if ($name === '') { + $name = 'DirectAdmin Import ' . now()->format('Y-m-d H:i'); + } + + $attributes = [ + 'name' => $name, + 'source_type' => 'directadmin', + 'import_method' => $this->importMethod, + 'import_options' => [ + 'files' => $this->importFiles, + 'databases' => $this->importDatabases, + 'emails' => $this->importEmails, + 'ssl' => $this->importSsl, + ], + 'status' => 'discovering', + 'progress' => 0, + 'current_task' => null, + ]; + + if ($this->importMethod === 'backup_file') { + $attributes['backup_path'] = $this->backupPath; + $attributes['remote_host'] = null; + $attributes['remote_port'] = null; + $attributes['remote_user'] = null; + } else { + $attributes['backup_path'] = null; + $attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null; + $attributes['remote_port'] = $this->remotePort; + $attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null; + + if (filled($this->remotePassword)) { + $attributes['remote_password'] = $this->remotePassword; + } + } + + $import = $this->importId ? ServerImport::find($this->importId) : null; + + if ($import) { + $import->update($attributes); + } else { + $import = ServerImport::create($attributes); + $this->importId = $import->id; + } + + $this->saveToSession(); + + return $import->fresh(); + } + + protected function getDiscoveredAccountsCount(): int + { + $import = $this->getImport(); + + return $import ? $import->accounts()->count() : 0; + } + + protected function getSelectedAccountsCount(): int + { + $import = $this->getImport(); + $selected = $import?->selected_accounts ?? []; + + return is_array($selected) ? count($selected) : 0; + } + + protected function getAccountsStepDescription(): string + { + $selected = $this->getSelectedAccountsCount(); + $total = $this->getDiscoveredAccountsCount(); + + if ($total === 0) { + return __('No accounts discovered yet.'); + } + + if ($selected === 0) { + return __(':count accounts discovered', ['count' => $total]); + } + + return __(':selected of :count accounts selected', ['selected' => $selected, 'count' => $total]); + } + + protected function saveToSession(): void + { + if ($this->importId) { + session()->put('directadmin_migration.import_id', $this->importId); + } + + session()->save(); + } + + protected function restoreFromSession(): void + { + $this->importId = session('directadmin_migration.import_id'); + } + + protected function restoreFromImport(): void + { + $import = $this->getImport(); + if (! $import) { + return; + } + + $this->name = $import->name; + $this->importMethod = (string) ($import->import_method ?? 'remote_server'); + $this->backupPath = $import->backup_path; + $this->remoteHost = $import->remote_host; + $this->remotePort = (int) ($import->remote_port ?? 2222); + $this->remoteUser = $import->remote_user; + + $options = $import->import_options ?? []; + if (is_array($options)) { + $this->importFiles = (bool) ($options['files'] ?? true); + $this->importDatabases = (bool) ($options['databases'] ?? true); + $this->importEmails = (bool) ($options['emails'] ?? true); + $this->importSsl = (bool) ($options['ssl'] ?? true); + } + + $this->step1Complete = $import->accounts()->exists(); + } +} diff --git a/app/Filament/Admin/Pages/Migration.php b/app/Filament/Admin/Pages/Migration.php index c55a195..0299a7b 100644 --- a/app/Filament/Admin/Pages/Migration.php +++ b/app/Filament/Admin/Pages/Migration.php @@ -41,19 +41,19 @@ class Migration extends Page implements HasForms public function getSubheading(): ?string { - return __('Migrate cPanel accounts directly or via WHM'); + return __('Migrate cPanel, WHM, or DirectAdmin accounts into Jabali'); } public function mount(): void { - if (! in_array($this->activeTab, ['cpanel', 'whm'], true)) { + if (! in_array($this->activeTab, ['cpanel', 'whm', 'directadmin'], true)) { $this->activeTab = 'cpanel'; } } public function updatedActiveTab(string $activeTab): void { - if (! in_array($activeTab, ['cpanel', 'whm'], true)) { + if (! in_array($activeTab, ['cpanel', 'whm', 'directadmin'], true)) { $this->activeTab = 'cpanel'; } } @@ -79,6 +79,11 @@ class Migration extends Page implements HasForms ->schema([ View::make('filament.admin.pages.migration-whm-tab'), ]), + 'directadmin' => Tabs\Tab::make(__('DirectAdmin Migration')) + ->icon('heroicon-o-arrow-down-tray') + ->schema([ + View::make('filament.admin.pages.migration-directadmin-tab'), + ]), ]), ]); } diff --git a/app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php b/app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php new file mode 100644 index 0000000..ac09f1d --- /dev/null +++ b/app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php @@ -0,0 +1,142 @@ +importId = $importId ?: session('directadmin_migration.import_id'); + } + + #[On('directadmin-config-updated')] + #[On('directadmin-selection-updated')] + public function refreshConfig(): void + { + $this->resetTable(); + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getImport(): ?ServerImport + { + if (! $this->importId) { + return null; + } + + return ServerImport::find($this->importId); + } + + /** + * @return array + */ + protected function getSelectedAccountIds(): array + { + $selected = $this->getImport()?->selected_accounts ?? []; + + return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : []))); + } + + /** + * @return \Illuminate\Support\Collection + */ + protected function getRecords() + { + if (! $this->importId) { + return collect(); + } + + $ids = $this->getSelectedAccountIds(); + if ($ids === []) { + return collect(); + } + + return ServerImportAccount::query() + ->where('server_import_id', $this->importId) + ->whereIn('id', $ids) + ->orderBy('source_username') + ->get(); + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + IconColumn::make('target_user_exists') + ->label(__('User')) + ->boolean() + ->trueIcon('heroicon-o-exclamation-triangle') + ->falseIcon('heroicon-o-user-plus') + ->trueColor('warning') + ->falseColor('success') + ->tooltip(fn (ServerImportAccount $record): string => User::where('username', $record->target_username)->exists() + ? __('User exists - migration will restore into the existing account') + : __('New user will be created')) + ->getStateUsing(fn (ServerImportAccount $record): bool => User::where('username', $record->target_username)->exists()), + TextColumn::make('source_username') + ->label(__('Source')) + ->weight('bold'), + TextColumn::make('main_domain') + ->label(__('Main Domain')) + ->wrap(), + TextInputColumn::make('target_username') + ->label(__('Target Username')) + ->rules([ + 'required', + 'max:32', + 'regex:/^[a-z0-9_]+$/i', + ]), + TextInputColumn::make('email') + ->label(__('Email')) + ->rules([ + 'nullable', + 'email', + 'max:255', + ]), + TextColumn::make('formatted_disk_usage') + ->label(__('Disk')) + ->toggleable(), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10) + ->emptyStateHeading(__('No accounts selected')) + ->emptyStateDescription(__('Go back and select accounts to migrate.')) + ->emptyStateIcon('heroicon-o-user-group'); + } + + public function render() + { + return $this->getTable()->render(); + } +} + diff --git a/app/Filament/Admin/Widgets/DirectAdminAccountsTable.php b/app/Filament/Admin/Widgets/DirectAdminAccountsTable.php new file mode 100644 index 0000000..985256f --- /dev/null +++ b/app/Filament/Admin/Widgets/DirectAdminAccountsTable.php @@ -0,0 +1,155 @@ +importId = $importId ?: session('directadmin_migration.import_id'); + } + + #[On('directadmin-accounts-updated')] + #[On('directadmin-selection-updated')] + public function refreshAccounts(): void + { + $this->resetTable(); + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getImport(): ?ServerImport + { + if (! $this->importId) { + return null; + } + + return ServerImport::find($this->importId); + } + + /** + * @return array + */ + protected function getSelectedAccountIds(): array + { + $selected = $this->getImport()?->selected_accounts ?? []; + + return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : []))); + } + + /** + * @return \Illuminate\Support\Collection + */ + protected function getRecords() + { + if (! $this->importId) { + return collect(); + } + + return ServerImportAccount::query() + ->where('server_import_id', $this->importId) + ->orderBy('source_username') + ->get(); + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + IconColumn::make('is_selected') + ->label('') + ->boolean() + ->trueIcon('heroicon-s-check-circle') + ->falseIcon('heroicon-o-minus-circle') + ->trueColor('primary') + ->falseColor('gray') + ->size(IconSize::Medium) + ->getStateUsing(fn (ServerImportAccount $record): bool => in_array($record->id, $this->getSelectedAccountIds(), true)), + TextColumn::make('source_username') + ->label(__('Username')) + ->weight('bold') + ->searchable(), + TextColumn::make('main_domain') + ->label(__('Main Domain')) + ->wrap() + ->searchable(), + TextColumn::make('email') + ->label(__('Email')) + ->icon('heroicon-o-envelope') + ->toggleable() + ->wrap(), + TextColumn::make('formatted_disk_usage') + ->label(__('Disk')) + ->toggleable(), + ]) + ->recordAction('toggleSelection') + ->actions([ + Action::make('toggleSelection') + ->label(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? __('Deselect') : __('Select')) + ->icon(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? 'heroicon-o-x-mark' : 'heroicon-o-check') + ->color(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? 'gray' : 'primary') + ->action(function (ServerImportAccount $record): void { + $import = $this->getImport(); + if (! $import) { + return; + } + + $selected = $this->getSelectedAccountIds(); + + if (in_array($record->id, $selected, true)) { + $selected = array_values(array_diff($selected, [$record->id])); + } else { + $selected[] = $record->id; + $selected = array_values(array_unique($selected)); + } + + $import->update(['selected_accounts' => $selected]); + + $this->dispatch('directadmin-selection-updated'); + $this->resetTable(); + }), + ]) + ->striped() + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(25) + ->emptyStateHeading(__('No accounts found')) + ->emptyStateDescription(__('Discover accounts to see them here.')) + ->emptyStateIcon('heroicon-o-user-group') + ->poll(null); + } + + public function render() + { + return $this->getTable()->render(); + } +} + diff --git a/app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php b/app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php new file mode 100644 index 0000000..1fd6a5f --- /dev/null +++ b/app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php @@ -0,0 +1,186 @@ +importId = $importId ?: session('directadmin_migration.import_id'); + } + + #[On('directadmin-selection-updated')] + public function refreshStatus(): void + { + $this->resetTable(); + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getImport(): ?ServerImport + { + if (! $this->importId) { + return null; + } + + return ServerImport::find($this->importId); + } + + /** + * @return array + */ + protected function getSelectedAccountIds(): array + { + $selected = $this->getImport()?->selected_accounts ?? []; + + return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : []))); + } + + /** + * @return \Illuminate\Support\Collection + */ + protected function getRecords() + { + if (! $this->importId) { + return collect(); + } + + $ids = $this->getSelectedAccountIds(); + if ($ids === []) { + return collect(); + } + + return ServerImportAccount::query() + ->where('server_import_id', $this->importId) + ->whereIn('id', $ids) + ->orderBy('source_username') + ->get(); + } + + protected function shouldPoll(): bool + { + $import = $this->getImport(); + if (! $import) { + return false; + } + + if (in_array($import->status, ['discovering', 'importing'], true)) { + return true; + } + + foreach ($this->getRecords() as $record) { + if (! in_array($record->status, ['completed', 'failed', 'skipped'], true)) { + return true; + } + } + + return false; + } + + protected function getStatusText(string $status): string + { + return match ($status) { + 'pending' => __('Waiting...'), + 'importing' => __('Importing...'), + 'completed' => __('Completed'), + 'failed' => __('Failed'), + 'skipped' => __('Skipped'), + default => __('Unknown'), + }; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + IconColumn::make('status_icon') + ->label('') + ->icon(fn (ServerImportAccount $record): string => match ($record->status) { + 'pending' => 'heroicon-o-clock', + 'importing' => 'heroicon-o-arrow-path', + 'completed' => 'heroicon-o-check-circle', + 'failed' => 'heroicon-o-x-circle', + 'skipped' => 'heroicon-o-minus-circle', + default => 'heroicon-o-question-mark-circle', + }) + ->color(fn (ServerImportAccount $record): string => match ($record->status) { + 'pending' => 'gray', + 'importing' => 'warning', + 'completed' => 'success', + 'failed' => 'danger', + 'skipped' => 'gray', + default => 'gray', + }) + ->size(IconSize::Small) + ->extraAttributes(fn (ServerImportAccount $record): array => $record->status === 'importing' + ? ['class' => 'animate-spin'] + : []), + TextColumn::make('source_username') + ->label(__('Account')) + ->weight(FontWeight::Bold) + ->searchable(), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (string $state): string => $this->getStatusText($state)) + ->color(fn (ServerImportAccount $record): string => match ($record->status) { + 'pending' => 'gray', + 'importing' => 'warning', + 'completed' => 'success', + 'failed' => 'danger', + 'skipped' => 'gray', + default => 'gray', + }), + TextColumn::make('current_task') + ->label(__('Current Task')) + ->wrap() + ->limit(80) + ->default(__('Waiting...')), + TextColumn::make('progress') + ->label(__('Progress')) + ->suffix('%') + ->toggleable(), + ]) + ->striped() + ->paginated(false) + ->poll($this->shouldPoll() ? '3s' : null) + ->emptyStateHeading(__('No selected accounts')) + ->emptyStateDescription(__('Select accounts and start migration.')) + ->emptyStateIcon('heroicon-o-queue-list'); + } + + public function render() + { + return $this->getTable()->render(); + } +} + diff --git a/app/Filament/Jabali/Pages/DirectAdminMigration.php b/app/Filament/Jabali/Pages/DirectAdminMigration.php new file mode 100644 index 0000000..86fc761 --- /dev/null +++ b/app/Filament/Jabali/Pages/DirectAdminMigration.php @@ -0,0 +1,615 @@ +label(__('Start Over')) + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->requiresConfirmation() + ->modalHeading(__('Start Over')) + ->modalDescription(__('This will reset the DirectAdmin migration wizard. Are you sure?')) + ->action('resetMigration'), + ]; + } + + public function mount(): void + { + $this->restoreFromSession(); + $this->restoreFromImport(); + } + + protected function getForms(): array + { + return ['migrationForm']; + } + + public function migrationForm(Schema $schema): Schema + { + return $schema->schema([ + Wizard::make([ + $this->getConnectStep(), + $this->getConfigureStep(), + $this->getMigrateStep(), + ]) + ->persistStepInQueryString('directadmin-step'), + ]); + } + + protected function getConnectStep(): Step + { + return Step::make(__('Connect')) + ->id('connect') + ->icon('heroicon-o-link') + ->description(__('Connect to DirectAdmin or upload a backup')) + ->schema([ + Section::make(__('Source')) + ->description(__('For now, migration requires a DirectAdmin backup archive. Remote migration will be added next.')) + ->icon('heroicon-o-server') + ->schema([ + Radio::make('importMethod') + ->label(__('Import Method')) + ->options([ + 'backup_file' => __('Backup File'), + 'remote_server' => __('Remote Server (Discovery only)'), + ]) + ->default('backup_file') + ->live(), + + Grid::make(['default' => 1, 'sm' => 2]) + ->schema([ + TextInput::make('remoteHost') + ->label(__('Host')) + ->placeholder('directadmin.example.com') + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + TextInput::make('remotePort') + ->label(__('Port')) + ->numeric() + ->default(2222) + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + TextInput::make('remoteUser') + ->label(__('Username')) + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + TextInput::make('remotePassword') + ->label(__('Password')) + ->password() + ->revealable() + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'), + ]), + + FileUpload::make('backupPath') + ->label(__('DirectAdmin Backup Archive')) + ->helperText(__('Upload a .tar.gz DirectAdmin backup file.')) + ->disk('local') + ->directory('imports/directadmin') + ->preserveFilenames() + ->acceptedFileTypes([ + 'application/gzip', + 'application/x-gzip', + 'application/x-tar', + 'application/octet-stream', + ]) + ->required() + ->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'), + + FormActions::make([ + Action::make('discoverAccount') + ->label(__('Discover Account')) + ->icon('heroicon-o-magnifying-glass') + ->color('primary') + ->action('discoverAccount'), + ])->alignEnd(), + ]), + + Section::make(__('Discovery')) + ->description(__('After discovery, you can choose what to import.')) + ->icon('heroicon-o-user') + ->schema([ + Text::make(__('Discovered account details will be used for migration.'))->color('gray'), + ]), + ]) + ->afterValidation(function () { + $import = $this->getImport(); + $hasAccounts = $import?->accounts()->exists() ?? false; + + if (! $hasAccounts) { + Notification::make() + ->title(__('No account discovered')) + ->body(__('Click "Discover Account" to continue.')) + ->danger() + ->send(); + + throw new Exception(__('No account discovered')); + } + + $this->step1Complete = true; + $this->saveToSession(); + }); + } + + protected function getConfigureStep(): Step + { + return Step::make(__('Configure')) + ->id('configure') + ->icon('heroicon-o-cog') + ->description(__('Choose what to import')) + ->schema([ + Section::make(__('What to Import')) + ->description(__('Select which parts of your account to import.')) + ->icon('heroicon-o-check-circle') + ->schema([ + Grid::make(['default' => 1, 'sm' => 2])->schema([ + Checkbox::make('importFiles') + ->label(__('Website Files')) + ->helperText(__('Restore website files from the backup')) + ->default(true), + Checkbox::make('importDatabases') + ->label(__('Databases')) + ->helperText(__('Restore MySQL databases and import dumps')) + ->default(true), + Checkbox::make('importEmails') + ->label(__('Email')) + ->helperText(__('Create email domains and mailboxes (limited in Phase 1)')) + ->default(true), + Checkbox::make('importSsl') + ->label(__('SSL')) + ->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)')) + ->default(true), + ]), + ]), + ]) + ->afterValidation(function (): void { + $import = $this->getImport(); + if (! $import) { + throw new Exception(__('Import job not found')); + } + + $import->update([ + 'import_options' => [ + 'files' => $this->importFiles, + 'databases' => $this->importDatabases, + 'emails' => $this->importEmails, + 'ssl' => $this->importSsl, + ], + ]); + + $this->saveToSession(); + }); + } + + protected function getMigrateStep(): Step + { + return Step::make(__('Migrate')) + ->id('migrate') + ->icon('heroicon-o-play') + ->description(__('Run the migration and watch progress')) + ->schema([ + FormActions::make([ + Action::make('startMigration') + ->label(__('Start Migration')) + ->icon('heroicon-o-play') + ->color('success') + ->requiresConfirmation() + ->modalHeading(__('Start Migration')) + ->modalDescription(__('This will import data into your Jabali account. Continue?')) + ->action('startMigration'), + + Action::make('newMigration') + ->label(__('New Migration')) + ->icon('heroicon-o-plus') + ->color('primary') + ->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed') + ->action('resetMigration'), + ])->alignEnd(), + + Section::make(__('Import Status')) + ->icon('heroicon-o-queue-list') + ->schema([ + View::make('filament.jabali.pages.directadmin-migration-status-table'), + ]), + ]); + } + + public function discoverAccount(): void + { + try { + $user = Auth::user(); + if (! $user) { + throw new Exception(__('You must be logged in.')); + } + + $import = $this->upsertImportForDiscovery(); + + $backupFullPath = null; + $remotePassword = null; + + if ($this->importMethod === 'backup_file') { + if (! $import->backup_path) { + throw new Exception(__('Please upload a DirectAdmin backup archive.')); + } + + $backupFullPath = Storage::disk('local')->path($import->backup_path); + } else { + $remotePassword = $this->remotePassword; + + if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) { + $remotePassword = (string) $import->remote_password; + } + + if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) { + throw new Exception(__('Please enter DirectAdmin host, port, username and password.')); + } + } + + $result = $this->getAgent()->importDiscover( + $import->id, + 'directadmin', + $import->import_method, + $backupFullPath, + $import->remote_host, + $import->remote_port ? (int) $import->remote_port : null, + $import->remote_user, + $remotePassword, + ); + + if (! ($result['success'] ?? false)) { + throw new Exception((string) ($result['error'] ?? __('Discovery failed'))); + } + + $accounts = $result['accounts'] ?? []; + if (! is_array($accounts) || $accounts === []) { + throw new Exception(__('No account was discovered.')); + } + + $account = null; + if (count($accounts) === 1) { + $account = $accounts[0]; + } else { + // Prefer matching the provided username if multiple accounts are returned. + foreach ($accounts as $candidate) { + if (! is_array($candidate)) { + continue; + } + if (($candidate['username'] ?? null) === $this->remoteUser) { + $account = $candidate; + break; + } + } + } + + if (! is_array($account)) { + throw new Exception(__('Multiple accounts were discovered. Please upload a single-user backup archive.')); + } + + $sourceUsername = trim((string) ($account['username'] ?? '')); + if ($sourceUsername === '') { + throw new Exception(__('Discovered account is missing a username.')); + } + + $import->accounts()->delete(); + + $record = ServerImportAccount::create([ + 'server_import_id' => $import->id, + 'source_username' => $sourceUsername, + 'target_username' => $user->username, + 'email' => (string) ($account['email'] ?? ''), + 'main_domain' => (string) ($account['main_domain'] ?? ''), + 'addon_domains' => $account['addon_domains'] ?? [], + 'subdomains' => $account['subdomains'] ?? [], + 'databases' => $account['databases'] ?? [], + 'email_accounts' => $account['email_accounts'] ?? [], + 'disk_usage' => (int) ($account['disk_usage'] ?? 0), + 'status' => 'pending', + 'progress' => 0, + 'current_task' => null, + 'import_log' => [], + 'error' => null, + ]); + + $import->update([ + 'discovered_accounts' => [$account], + 'selected_accounts' => [$record->id], + 'status' => 'ready', + 'progress' => 0, + 'current_task' => null, + 'errors' => [], + ]); + + $this->importId = $import->id; + $this->step1Complete = true; + $this->saveToSession(); + + $this->dispatch('directadmin-self-status-updated'); + + Notification::make() + ->title(__('Account discovered')) + ->body(__('Ready to migrate into your Jabali account (:username).', ['username' => $user->username])) + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title(__('Discovery failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + + public function startMigration(): void + { + $import = $this->getImport(); + if (! $import) { + Notification::make() + ->title(__('Import job not found')) + ->danger() + ->send(); + return; + } + + $selected = $import->selected_accounts ?? []; + if (! is_array($selected) || $selected === []) { + Notification::make() + ->title(__('No account selected')) + ->danger() + ->send(); + return; + } + + if ($import->import_method === 'remote_server') { + Notification::make() + ->title(__('Remote DirectAdmin import is not available yet')) + ->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.')) + ->warning() + ->send(); + return; + } + + $import->update([ + 'status' => 'importing', + 'started_at' => now(), + ]); + + $result = $this->getAgent()->importStart($import->id); + + if (! ($result['success'] ?? false)) { + Notification::make() + ->title(__('Failed to start migration')) + ->body((string) ($result['error'] ?? __('Unknown error'))) + ->danger() + ->send(); + return; + } + + Notification::make() + ->title(__('Migration started')) + ->body(__('Import process has started in the background.')) + ->success() + ->send(); + + $this->dispatch('directadmin-self-status-updated'); + } + + public function resetMigration(): void + { + if ($this->importId) { + ServerImport::whereKey($this->importId)->delete(); + } + + session()->forget('directadmin_self_migration.import_id'); + + $this->wizardStep = null; + $this->step1Complete = false; + $this->importId = null; + $this->importMethod = 'backup_file'; + $this->remoteHost = null; + $this->remotePort = 2222; + $this->remoteUser = null; + $this->remotePassword = null; + $this->backupPath = null; + $this->importFiles = true; + $this->importDatabases = true; + $this->importEmails = true; + $this->importSsl = true; + } + + protected function getAgent(): AgentClient + { + return $this->agent ??= new AgentClient; + } + + protected function getImport(): ?ServerImport + { + if (! $this->importId) { + return null; + } + + return ServerImport::with('accounts')->find($this->importId); + } + + protected function upsertImportForDiscovery(): ServerImport + { + $user = Auth::user(); + $name = $user ? ('DirectAdmin Import - ' . $user->username . ' - ' . now()->format('Y-m-d H:i')) : ('DirectAdmin Import ' . now()->format('Y-m-d H:i')); + + $attributes = [ + 'name' => $name, + 'source_type' => 'directadmin', + 'import_method' => $this->importMethod, + 'import_options' => [ + 'files' => $this->importFiles, + 'databases' => $this->importDatabases, + 'emails' => $this->importEmails, + 'ssl' => $this->importSsl, + ], + 'status' => 'discovering', + 'progress' => 0, + 'current_task' => null, + ]; + + if ($this->importMethod === 'backup_file') { + $attributes['backup_path'] = $this->backupPath; + $attributes['remote_host'] = null; + $attributes['remote_port'] = null; + $attributes['remote_user'] = null; + } else { + $attributes['backup_path'] = null; + $attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null; + $attributes['remote_port'] = $this->remotePort; + $attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null; + + if (filled($this->remotePassword)) { + $attributes['remote_password'] = $this->remotePassword; + } + } + + $import = $this->importId ? ServerImport::find($this->importId) : null; + + if ($import) { + $import->update($attributes); + } else { + $import = ServerImport::create($attributes); + $this->importId = $import->id; + } + + $this->saveToSession(); + + return $import->fresh(); + } + + protected function saveToSession(): void + { + if ($this->importId) { + session()->put('directadmin_self_migration.import_id', $this->importId); + } + + session()->save(); + } + + protected function restoreFromSession(): void + { + $this->importId = session('directadmin_self_migration.import_id'); + } + + protected function restoreFromImport(): void + { + $import = $this->getImport(); + if (! $import) { + return; + } + + $this->importMethod = (string) ($import->import_method ?? 'backup_file'); + $this->backupPath = $import->backup_path; + $this->remoteHost = $import->remote_host; + $this->remotePort = (int) ($import->remote_port ?? 2222); + $this->remoteUser = $import->remote_user; + + $options = $import->import_options ?? []; + if (is_array($options)) { + $this->importFiles = (bool) ($options['files'] ?? true); + $this->importDatabases = (bool) ($options['databases'] ?? true); + $this->importEmails = (bool) ($options['emails'] ?? true); + $this->importSsl = (bool) ($options['ssl'] ?? true); + } + + $this->step1Complete = $import->accounts()->exists(); + } +} + diff --git a/app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php b/app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php new file mode 100644 index 0000000..d66f6ed --- /dev/null +++ b/app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php @@ -0,0 +1,170 @@ +importId = $importId ?: session('directadmin_self_migration.import_id'); + } + + #[On('directadmin-self-status-updated')] + public function refreshStatus(): void + { + $this->resetTable(); + } + + public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver + { + return null; + } + + protected function getImport(): ?ServerImport + { + if (! $this->importId) { + return null; + } + + return ServerImport::find($this->importId); + } + + /** + * @return \Illuminate\Support\Collection + */ + protected function getRecords() + { + if (! $this->importId) { + return collect(); + } + + return ServerImportAccount::query() + ->where('server_import_id', $this->importId) + ->orderBy('source_username') + ->get(); + } + + protected function shouldPoll(): bool + { + $import = $this->getImport(); + if (! $import) { + return false; + } + + if (in_array($import->status, ['discovering', 'importing'], true)) { + return true; + } + + foreach ($this->getRecords() as $record) { + if (! in_array($record->status, ['completed', 'failed', 'skipped'], true)) { + return true; + } + } + + return false; + } + + protected function getStatusText(string $status): string + { + return match ($status) { + 'pending' => __('Waiting...'), + 'importing' => __('Importing...'), + 'completed' => __('Completed'), + 'failed' => __('Failed'), + 'skipped' => __('Skipped'), + default => __('Unknown'), + }; + } + + public function table(Table $table): Table + { + return $table + ->records(fn () => $this->getRecords()) + ->columns([ + IconColumn::make('status_icon') + ->label('') + ->icon(fn (ServerImportAccount $record): string => match ($record->status) { + 'pending' => 'heroicon-o-clock', + 'importing' => 'heroicon-o-arrow-path', + 'completed' => 'heroicon-o-check-circle', + 'failed' => 'heroicon-o-x-circle', + 'skipped' => 'heroicon-o-minus-circle', + default => 'heroicon-o-question-mark-circle', + }) + ->color(fn (ServerImportAccount $record): string => match ($record->status) { + 'pending' => 'gray', + 'importing' => 'warning', + 'completed' => 'success', + 'failed' => 'danger', + 'skipped' => 'gray', + default => 'gray', + }) + ->size(IconSize::Small) + ->extraAttributes(fn (ServerImportAccount $record): array => $record->status === 'importing' + ? ['class' => 'animate-spin'] + : []), + TextColumn::make('source_username') + ->label(__('Account')) + ->weight(FontWeight::Bold) + ->searchable(), + TextColumn::make('status') + ->label(__('Status')) + ->badge() + ->formatStateUsing(fn (string $state): string => $this->getStatusText($state)) + ->color(fn (ServerImportAccount $record): string => match ($record->status) { + 'pending' => 'gray', + 'importing' => 'warning', + 'completed' => 'success', + 'failed' => 'danger', + 'skipped' => 'gray', + default => 'gray', + }), + TextColumn::make('current_task') + ->label(__('Current Task')) + ->wrap() + ->limit(80) + ->default(__('Waiting...')), + TextColumn::make('progress') + ->label(__('Progress')) + ->suffix('%') + ->toggleable(), + ]) + ->striped() + ->paginated(false) + ->poll($this->shouldPoll() ? '3s' : null) + ->emptyStateHeading(__('No migration activity')) + ->emptyStateDescription(__('Discover an account and start migration.')) + ->emptyStateIcon('heroicon-o-queue-list'); + } + + public function render() + { + return $this->getTable()->render(); + } +} + diff --git a/bin/jabali-agent b/bin/jabali-agent index 938f202..d030618 100755 --- a/bin/jabali-agent +++ b/bin/jabali-agent @@ -12901,6 +12901,43 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string $url = "https://$host:$port/CMD_API_SHOW_ALL_USERS"; + $fetchUserConfig = function (string $username) use ($host, $port, $user, $password): ?array { + $detailUrl = "https://$host:$port/CMD_API_SHOW_USER_CONFIG?user=" . urlencode($username); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $detailUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + curl_setopt($ch, CURLOPT_USERPWD, "$user:$password"); + + $userResponse = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error || $httpCode !== 200 || !is_string($userResponse) || $userResponse === '') { + return null; + } + + parse_str($userResponse, $userData); + if (isset($userData['error']) && $userData['error'] === '1') { + return null; + } + + return [ + 'username' => $username, + 'email' => $userData['email'] ?? '', + 'main_domain' => $userData['domain'] ?? '', + 'addon_domains' => [], + 'subdomains' => [], + 'databases' => [], + 'email_accounts' => [], + 'disk_usage' => ($userData['bandwidth'] ?? 0) * 1048576, + ]; + }; + $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); @@ -12926,6 +12963,13 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string parse_str($response, $data); if (isset($data['error']) && $data['error'] === '1') { + // Regular DirectAdmin users are not allowed to call CMD_API_SHOW_ALL_USERS. + // In that case, fall back to discovering a single account using the same credentials. + $single = $fetchUserConfig($user); + if ($single) { + return ['success' => true, 'accounts' => [$single]]; + } + return ['success' => false, 'error' => $data['text'] ?? 'Unknown error']; } @@ -12937,35 +12981,22 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string $userList = [$userList]; } + if (empty($userList)) { + $single = $fetchUserConfig($user); + if ($single) { + return ['success' => true, 'accounts' => [$single]]; + } + + return ['success' => false, 'error' => 'No users returned by DirectAdmin']; + } + foreach ($userList as $username) { if (empty($username)) continue; - // Get user details - $detailUrl = "https://$host:$port/CMD_API_SHOW_USER_CONFIG?user=" . urlencode($username); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $detailUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); - curl_setopt($ch, CURLOPT_USERPWD, "$user:$password"); - - $userResponse = curl_exec($ch); - curl_close($ch); - - parse_str($userResponse, $userData); - - $accounts[] = [ - 'username' => $username, - 'email' => $userData['email'] ?? '', - 'main_domain' => $userData['domain'] ?? '', - 'addon_domains' => [], - 'subdomains' => [], - 'databases' => [], - 'email_accounts' => [], - 'disk_usage' => ($userData['bandwidth'] ?? 0) * 1048576, - ]; + $account = $fetchUserConfig($username); + if ($account) { + $accounts[] = $account; + } } return ['success' => true, 'accounts' => $accounts]; diff --git a/resources/views/filament/admin/pages/directadmin-account-config-table.blade.php b/resources/views/filament/admin/pages/directadmin-account-config-table.blade.php new file mode 100644 index 0000000..6976198 --- /dev/null +++ b/resources/views/filament/admin/pages/directadmin-account-config-table.blade.php @@ -0,0 +1,4 @@ +@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountConfigTable::class, [ + 'importId' => $this->importId, +], key('directadmin-account-config-table-' . ($this->importId ?? 'new'))) + diff --git a/resources/views/filament/admin/pages/directadmin-accounts-table.blade.php b/resources/views/filament/admin/pages/directadmin-accounts-table.blade.php new file mode 100644 index 0000000..f28a726 --- /dev/null +++ b/resources/views/filament/admin/pages/directadmin-accounts-table.blade.php @@ -0,0 +1,4 @@ +@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountsTable::class, [ + 'importId' => $this->importId, +], key('directadmin-accounts-table-' . ($this->importId ?? 'new'))) + diff --git a/resources/views/filament/admin/pages/directadmin-migration-status-table.blade.php b/resources/views/filament/admin/pages/directadmin-migration-status-table.blade.php new file mode 100644 index 0000000..ea84df6 --- /dev/null +++ b/resources/views/filament/admin/pages/directadmin-migration-status-table.blade.php @@ -0,0 +1,4 @@ +@livewire(\App\Filament\Admin\Widgets\DirectAdminMigrationStatusTable::class, [ + 'importId' => $this->importId, +], key('directadmin-migration-status-table-' . ($this->importId ?? 'new'))) + diff --git a/resources/views/filament/admin/pages/directadmin-migration.blade.php b/resources/views/filament/admin/pages/directadmin-migration.blade.php new file mode 100644 index 0000000..03136c4 --- /dev/null +++ b/resources/views/filament/admin/pages/directadmin-migration.blade.php @@ -0,0 +1,6 @@ + + {{ $this->migrationForm }} + + + + diff --git a/resources/views/filament/admin/pages/migration-directadmin-tab.blade.php b/resources/views/filament/admin/pages/migration-directadmin-tab.blade.php new file mode 100644 index 0000000..2501136 --- /dev/null +++ b/resources/views/filament/admin/pages/migration-directadmin-tab.blade.php @@ -0,0 +1,2 @@ +@livewire(\App\Filament\Admin\Pages\DirectAdminMigration::class, [], key('migration-directadmin')) + diff --git a/resources/views/filament/jabali/pages/directadmin-migration-status-table.blade.php b/resources/views/filament/jabali/pages/directadmin-migration-status-table.blade.php new file mode 100644 index 0000000..fc9f3c7 --- /dev/null +++ b/resources/views/filament/jabali/pages/directadmin-migration-status-table.blade.php @@ -0,0 +1,4 @@ +@livewire(\App\Filament\Jabali\Widgets\DirectAdminMigrationStatusTable::class, [ + 'importId' => $this->importId, +], key('directadmin-self-migration-status-table-' . ($this->importId ?? 'new'))) + diff --git a/resources/views/filament/jabali/pages/directadmin-migration.blade.php b/resources/views/filament/jabali/pages/directadmin-migration.blade.php new file mode 100644 index 0000000..03136c4 --- /dev/null +++ b/resources/views/filament/jabali/pages/directadmin-migration.blade.php @@ -0,0 +1,6 @@ + + {{ $this->migrationForm }} + + + +