Compare commits
12 Commits
386c759e70
...
2bdf7395fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bdf7395fc | |||
| c4acf0b658 | |||
| ed5e3f2bda | |||
| 070e46cf77 | |||
| a566a2ae64 | |||
| 1e66f43d4e | |||
| 443b05a677 | |||
| 13685615cb | |||
| e7920366d7 | |||
| 3fa6399b27 | |||
| e22d73eba5 | |||
| a9f8670224 |
6
.stylelintignore
Normal file
6
.stylelintignore
Normal file
@@ -0,0 +1,6 @@
|
||||
vendor/
|
||||
node_modules/
|
||||
public/build/
|
||||
public/vendor/
|
||||
public/fonts/
|
||||
public/css/filament/
|
||||
18
.stylelintrc.json
Normal file
18
.stylelintrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"rules": {
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": [
|
||||
"tailwind",
|
||||
"apply",
|
||||
"layer",
|
||||
"variants",
|
||||
"responsive",
|
||||
"screen",
|
||||
"theme"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
21
README.md
21
README.md
@@ -5,7 +5,7 @@
|
||||
|
||||
A modern web hosting control panel for WordPress and general PHP hosting. Jabali focuses on clean multi-tenant isolation, safe automation, and a consistent admin/user experience. It ships with a privileged agent for root-level tasks, built-in mail and DNS management, migrations from common panels, and a security center that keeps critical services in check. The UI is designed to be fast, predictable, and easy to operate on a single server.
|
||||
|
||||
Version: 0.9-rc51 (release candidate)
|
||||
Version: see `VERSION` (release candidate)
|
||||
|
||||
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
||||
|
||||
@@ -27,6 +27,25 @@ This is a release candidate. Expect rapid iteration and breaking changes until 1
|
||||
- Security center with firewall, Fail2ban, ClamAV, and scanners
|
||||
- Audit logs and admin notifications
|
||||
|
||||
## Screenshots
|
||||
|
||||
Admin panel:
|
||||
- [Admin Dashboard](docs/screenshots/admin-dashboard.png)
|
||||
- [Admin Server Status](docs/screenshots/admin-server-status.png)
|
||||
- [Admin Server Settings](docs/screenshots/admin-server-settings.png)
|
||||
- [Admin Security](docs/screenshots/admin-security.png)
|
||||
- [Admin Users](docs/screenshots/admin-users.png)
|
||||
- [Admin SSL Manager](docs/screenshots/admin-ssl-manager.png)
|
||||
- [Admin DNS Zones](docs/screenshots/admin-dns-zones.png)
|
||||
- [Admin Backups](docs/screenshots/admin-backups.png)
|
||||
- [Admin Services](docs/screenshots/admin-services.png)
|
||||
|
||||
User panel:
|
||||
- [User Dashboard](docs/screenshots/user-dashboard.png)
|
||||
- [User Domains](docs/screenshots/user-domains.png)
|
||||
- [User Backups](docs/screenshots/user-backups.png)
|
||||
- [User cPanel Migration](docs/screenshots/user-cpanel-migration.png)
|
||||
|
||||
## Installation
|
||||
|
||||
GitHub install:
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BackupSchedule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'destination_id',
|
||||
'name',
|
||||
'is_active',
|
||||
'is_server_backup',
|
||||
'frequency',
|
||||
'time',
|
||||
'day_of_week',
|
||||
'day_of_month',
|
||||
'include_files',
|
||||
'include_databases',
|
||||
'include_mailboxes',
|
||||
'include_dns',
|
||||
'domains',
|
||||
'databases',
|
||||
'mailboxes',
|
||||
'users',
|
||||
'retention_count',
|
||||
'last_run_at',
|
||||
'next_run_at',
|
||||
'last_status',
|
||||
'last_error',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'is_server_backup' => 'boolean',
|
||||
'include_files' => 'boolean',
|
||||
'include_databases' => 'boolean',
|
||||
'include_mailboxes' => 'boolean',
|
||||
'include_dns' => 'boolean',
|
||||
'domains' => 'array',
|
||||
'databases' => 'array',
|
||||
'mailboxes' => 'array',
|
||||
'users' => 'array',
|
||||
'metadata' => 'array',
|
||||
'retention_count' => 'integer',
|
||||
'day_of_week' => 'integer',
|
||||
'day_of_month' => 'integer',
|
||||
'last_run_at' => 'datetime',
|
||||
'next_run_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function destination(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupDestination::class, 'destination_id');
|
||||
}
|
||||
|
||||
public function backups(): HasMany
|
||||
{
|
||||
return $this->hasMany(Backup::class, 'schedule_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the schedule should run now.
|
||||
*/
|
||||
public function shouldRun(): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->next_run_at) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->next_run_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and set the next run time.
|
||||
*/
|
||||
public function calculateNextRun(): Carbon
|
||||
{
|
||||
$timezone = $this->getSystemTimezone();
|
||||
$now = Carbon::now($timezone);
|
||||
$time = explode(':', $this->time);
|
||||
$hour = (int) ($time[0] ?? 2);
|
||||
$minute = (int) ($time[1] ?? 0);
|
||||
|
||||
$next = $now->copy()->setTime($hour, $minute, 0);
|
||||
|
||||
// If time already passed today, start from tomorrow
|
||||
if ($next->isPast()) {
|
||||
$next->addDay();
|
||||
}
|
||||
|
||||
switch ($this->frequency) {
|
||||
case 'hourly':
|
||||
$next = $now->copy()->addHour()->startOfHour();
|
||||
break;
|
||||
|
||||
case 'daily':
|
||||
// Already set to next occurrence
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$targetDay = $this->day_of_week ?? 0; // Default to Sunday
|
||||
while ($next->dayOfWeek !== $targetDay) {
|
||||
$next->addDay();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$targetDay = $this->day_of_month ?? 1;
|
||||
$next->day = min($targetDay, $next->daysInMonth);
|
||||
if ($next->isPast()) {
|
||||
$next->addMonth();
|
||||
$next->day = min($targetDay, $next->daysInMonth);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$nextUtc = $next->copy()->setTimezone('UTC');
|
||||
$this->attributes['next_run_at'] = $nextUtc->format($this->getDateFormat());
|
||||
|
||||
return $nextUtc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frequency label for UI.
|
||||
*/
|
||||
public function getFrequencyLabelAttribute(): string
|
||||
{
|
||||
$base = match ($this->frequency) {
|
||||
'hourly' => 'Every hour',
|
||||
'daily' => 'Daily at '.$this->time,
|
||||
'weekly' => 'Weekly on '.$this->getDayName().' at '.$this->time,
|
||||
'monthly' => 'Monthly on day '.($this->day_of_month ?? 1).' at '.$this->time,
|
||||
default => ucfirst($this->frequency),
|
||||
};
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day name for weekly schedules.
|
||||
*/
|
||||
protected function getDayName(): string
|
||||
{
|
||||
$days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
return $days[$this->day_of_week ?? 0];
|
||||
}
|
||||
|
||||
protected function getSystemTimezone(): string
|
||||
{
|
||||
static $timezone = null;
|
||||
if ($timezone === null) {
|
||||
$timezone = trim((string) @file_get_contents('/etc/timezone'));
|
||||
if ($timezone === '') {
|
||||
$timezone = trim((string) @shell_exec('timedatectl show -p Timezone --value 2>/dev/null'));
|
||||
}
|
||||
if ($timezone === '') {
|
||||
$timezone = 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
return $timezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for active schedules.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for due schedules.
|
||||
*/
|
||||
public function scopeDue($query)
|
||||
{
|
||||
return $query->active()
|
||||
->where(function ($q) {
|
||||
$q->whereNull('next_run_at')
|
||||
->orWhere('next_run_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for user schedules.
|
||||
*/
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for server backup schedules.
|
||||
*/
|
||||
public function scopeServerBackups($query)
|
||||
{
|
||||
return $query->where('is_server_backup', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last status color for UI.
|
||||
*/
|
||||
public function getLastStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->last_status) {
|
||||
'success' => 'success',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
1616
app/Backups.php
1616
app/Backups.php
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ use Illuminate\Support\Str;
|
||||
class ImportProcessCommand extends Command
|
||||
{
|
||||
protected $signature = 'import:process {import_id : The server import ID to process}';
|
||||
|
||||
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
|
||||
|
||||
private ?AgentClient $agent = null;
|
||||
@@ -27,8 +28,9 @@ class ImportProcessCommand extends Command
|
||||
$importId = (int) $this->argument('import_id');
|
||||
|
||||
$import = ServerImport::with('accounts')->find($importId);
|
||||
if (!$import) {
|
||||
if (! $import) {
|
||||
$this->error("Import not found: $importId");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ class ImportProcessCommand extends Command
|
||||
'current_task' => null,
|
||||
]);
|
||||
$import->addError('No accounts selected for import');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -67,8 +70,8 @@ class ImportProcessCommand extends Command
|
||||
'status' => 'failed',
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$account->addLog("Import failed: " . $e->getMessage());
|
||||
$import->addError("Account {$account->source_username}: " . $e->getMessage());
|
||||
$account->addLog('Import failed: '.$e->getMessage());
|
||||
$import->addError("Account {$account->source_username}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +99,10 @@ class ImportProcessCommand extends Command
|
||||
'completed_at' => now(),
|
||||
'progress' => 100,
|
||||
]);
|
||||
$import->addLog("All accounts imported successfully");
|
||||
$import->addLog('All accounts imported successfully');
|
||||
}
|
||||
|
||||
$this->info("Import completed. Success: " . ($totalAccounts - $failedCount) . ", Failed: $failedCount");
|
||||
$this->info('Import completed. Success: '.($totalAccounts - $failedCount).", Failed: $failedCount");
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -107,8 +110,9 @@ class ImportProcessCommand extends Command
|
||||
private function getAgent(): AgentClient
|
||||
{
|
||||
if ($this->agent === null) {
|
||||
$this->agent = new AgentClient();
|
||||
$this->agent = new AgentClient;
|
||||
}
|
||||
|
||||
return $this->agent;
|
||||
}
|
||||
|
||||
@@ -132,28 +136,28 @@ class ImportProcessCommand extends Command
|
||||
if ($account->main_domain) {
|
||||
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
|
||||
$this->createDomains($account, $user);
|
||||
$account->addLog("Created domains");
|
||||
$account->addLog('Created domains');
|
||||
}
|
||||
|
||||
// Step 3: Import files
|
||||
if ($options['files'] ?? true) {
|
||||
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
|
||||
$this->importFiles($import, $account, $user);
|
||||
$account->addLog("Files imported");
|
||||
$account->addLog('Files imported');
|
||||
}
|
||||
|
||||
// Step 4: Import databases
|
||||
if (($options['databases'] ?? true) && !empty($account->databases)) {
|
||||
if (($options['databases'] ?? true) && ! empty($account->databases)) {
|
||||
$account->update(['current_task' => 'Importing databases...', 'progress' => 60]);
|
||||
$this->importDatabases($import, $account, $user);
|
||||
$account->addLog("Databases imported");
|
||||
$account->addLog('Databases imported');
|
||||
}
|
||||
|
||||
// Step 5: Import emails
|
||||
if (($options['emails'] ?? true) && !empty($account->email_accounts)) {
|
||||
if (($options['emails'] ?? true) && ! empty($account->email_accounts)) {
|
||||
$account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]);
|
||||
$this->importEmails($import, $account, $user);
|
||||
$account->addLog("Email accounts imported");
|
||||
$account->addLog('Email accounts imported');
|
||||
}
|
||||
|
||||
$account->update([
|
||||
@@ -161,7 +165,7 @@ class ImportProcessCommand extends Command
|
||||
'progress' => 100,
|
||||
'current_task' => null,
|
||||
]);
|
||||
$account->addLog("Import completed successfully");
|
||||
$account->addLog('Import completed successfully');
|
||||
}
|
||||
|
||||
private function createUser(ServerImportAccount $account): User
|
||||
@@ -170,6 +174,7 @@ class ImportProcessCommand extends Command
|
||||
$existingUser = User::where('username', $account->target_username)->first();
|
||||
if ($existingUser) {
|
||||
$account->addLog("User already exists: {$account->target_username}");
|
||||
|
||||
return $existingUser;
|
||||
}
|
||||
|
||||
@@ -179,8 +184,8 @@ class ImportProcessCommand extends Command
|
||||
// Create user via agent
|
||||
$result = $this->getAgent()->createUser($account->target_username, $password);
|
||||
|
||||
if (!($result['success'] ?? false)) {
|
||||
throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error'));
|
||||
if (! ($result['success'] ?? false)) {
|
||||
throw new Exception('Failed to create system user: '.($result['error'] ?? 'Unknown error'));
|
||||
}
|
||||
|
||||
// Create user in database
|
||||
@@ -191,7 +196,7 @@ class ImportProcessCommand extends Command
|
||||
'password' => Hash::make($password),
|
||||
]);
|
||||
|
||||
$account->addLog("Created user with temporary password. User should reset password.");
|
||||
$account->addLog('Created user with temporary password. User should reset password.');
|
||||
|
||||
return $user;
|
||||
}
|
||||
@@ -201,7 +206,7 @@ class ImportProcessCommand extends Command
|
||||
// Create main domain
|
||||
if ($account->main_domain) {
|
||||
$existingDomain = Domain::where('domain', $account->main_domain)->first();
|
||||
if (!$existingDomain) {
|
||||
if (! $existingDomain) {
|
||||
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
|
||||
|
||||
if ($result['success'] ?? false) {
|
||||
@@ -213,7 +218,7 @@ class ImportProcessCommand extends Command
|
||||
]);
|
||||
$account->addLog("Created main domain: {$account->main_domain}");
|
||||
} else {
|
||||
$account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown'));
|
||||
$account->addLog('Warning: Failed to create main domain: '.($result['error'] ?? 'Unknown'));
|
||||
}
|
||||
} else {
|
||||
$account->addLog("Main domain already exists: {$account->main_domain}");
|
||||
@@ -223,7 +228,7 @@ class ImportProcessCommand extends Command
|
||||
// Create addon domains
|
||||
foreach ($account->addon_domains ?? [] as $domain) {
|
||||
$existingDomain = Domain::where('domain', $domain)->first();
|
||||
if (!$existingDomain) {
|
||||
if (! $existingDomain) {
|
||||
$result = $this->getAgent()->domainCreate($user->username, $domain);
|
||||
|
||||
if ($result['success'] ?? false) {
|
||||
@@ -243,31 +248,38 @@ class ImportProcessCommand extends Command
|
||||
|
||||
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
|
||||
{
|
||||
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
|
||||
$account->addLog("File import skipped - not a backup file import");
|
||||
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
|
||||
$account->addLog('File import skipped - not a backup file import');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$backupPath = Storage::disk('local')->path($import->backup_path);
|
||||
if (!file_exists($backupPath)) {
|
||||
$account->addLog("Warning: Backup file not found");
|
||||
$backupPath = $this->resolveBackupFullPath($import);
|
||||
if (! $backupPath) {
|
||||
$account->addLog('Warning: Backup file not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$extractDir = "/tmp/import_{$import->id}_{$account->id}_" . time();
|
||||
if (!mkdir($extractDir, 0755, true)) {
|
||||
$account->addLog("Warning: Failed to create extraction directory");
|
||||
$extractDir = "/tmp/import_{$import->id}_{$account->id}_".time();
|
||||
if (! mkdir($extractDir, 0755, true)) {
|
||||
$account->addLog('Warning: Failed to create extraction directory');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$username = $account->source_username;
|
||||
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
|
||||
|
||||
if ($import->source_type === 'cpanel') {
|
||||
// Extract home directory from cPanel backup
|
||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
||||
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||
" --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null";
|
||||
exec($cmd, $output, $code);
|
||||
if ($code !== 0) {
|
||||
$account->addLog('Warning: Failed to extract backup archive');
|
||||
}
|
||||
|
||||
// Find extracted files
|
||||
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
|
||||
@@ -280,17 +292,20 @@ class ImportProcessCommand extends Command
|
||||
if (is_dir($publicHtml) && $account->main_domain) {
|
||||
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public";
|
||||
if (is_dir($destDir)) {
|
||||
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
|
||||
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
|
||||
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
|
||||
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||
$account->addLog("Copied public_html to {$account->main_domain}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Extract from DirectAdmin backup
|
||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
||||
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||
" --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null";
|
||||
exec($cmd, $output, $code);
|
||||
if ($code !== 0) {
|
||||
$account->addLog('Warning: Failed to extract DirectAdmin backup archive');
|
||||
}
|
||||
|
||||
// Find domain directories
|
||||
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
|
||||
@@ -303,8 +318,8 @@ class ImportProcessCommand extends Command
|
||||
if (is_dir($publicHtml)) {
|
||||
$destDir = "/home/{$user->username}/domains/{$domain}/public";
|
||||
if (is_dir($destDir)) {
|
||||
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
|
||||
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
|
||||
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
|
||||
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||
$account->addLog("Copied files for domain: {$domain}");
|
||||
}
|
||||
}
|
||||
@@ -312,67 +327,115 @@ class ImportProcessCommand extends Command
|
||||
}
|
||||
} finally {
|
||||
// Cleanup
|
||||
exec("rm -rf " . escapeshellarg($extractDir));
|
||||
exec('rm -rf '.escapeshellarg($extractDir));
|
||||
}
|
||||
}
|
||||
|
||||
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
|
||||
{
|
||||
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
|
||||
$account->addLog("Database import skipped - not a backup file import");
|
||||
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
|
||||
$account->addLog('Database import skipped - not a backup file import');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$backupPath = Storage::disk('local')->path($import->backup_path);
|
||||
if (!file_exists($backupPath)) {
|
||||
$backupPath = $this->resolveBackupFullPath($import);
|
||||
if (! $backupPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_" . time();
|
||||
if (!mkdir($extractDir, 0755, true)) {
|
||||
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_".time();
|
||||
if (! mkdir($extractDir, 0755, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
|
||||
|
||||
// Extract MySQL dumps
|
||||
if ($import->source_type === 'cpanel') {
|
||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
||||
" --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null";
|
||||
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||
" --wildcards '*/mysql/*.sql*' 'mysql/*.sql*' 2>/dev/null";
|
||||
} else {
|
||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
||||
" --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null";
|
||||
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||
" --wildcards 'backup/databases/*.sql*' 'databases/*.sql*' 2>/dev/null";
|
||||
}
|
||||
exec($cmd, $output, $code);
|
||||
if ($code !== 0) {
|
||||
$account->addLog('Warning: Failed to extract database dumps from backup archive');
|
||||
}
|
||||
|
||||
// Find SQL files
|
||||
$sqlFiles = [];
|
||||
exec("find " . escapeshellarg($extractDir) . " -name '*.sql' -type f 2>/dev/null", $sqlFiles);
|
||||
exec('find '.escapeshellarg($extractDir)." -type f \\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.sql.zst' \\) 2>/dev/null", $sqlFiles);
|
||||
|
||||
foreach ($sqlFiles as $sqlFile) {
|
||||
$dbName = basename($sqlFile, '.sql');
|
||||
$fileName = basename($sqlFile);
|
||||
$dbName = preg_replace('/\\.(sql|sql\\.gz|sql\\.zst)$/i', '', $fileName);
|
||||
|
||||
if (! is_string($dbName) || $dbName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create database name with user prefix
|
||||
$newDbName = substr($user->username . '_' . preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
|
||||
$newDbName = substr($user->username.'_'.preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
|
||||
|
||||
// Create database via agent
|
||||
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
|
||||
|
||||
if ($result['success'] ?? false) {
|
||||
// Import data
|
||||
$cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1";
|
||||
exec($cmd, $importOutput, $importCode);
|
||||
$sqlToImport = $sqlFile;
|
||||
$tmpSql = null;
|
||||
|
||||
if ($importCode === 0) {
|
||||
$account->addLog("Imported database: {$newDbName}");
|
||||
} else {
|
||||
$account->addLog("Warning: Database created but import failed: {$newDbName}");
|
||||
try {
|
||||
$lower = strtolower($sqlFile);
|
||||
|
||||
if (str_ends_with($lower, '.sql.gz')) {
|
||||
$tmpSql = $extractDir.'/import_'.$account->id.'_'.$dbName.'_'.uniqid('', true).'.sql';
|
||||
$decompressCmd = 'gzip -dc '.escapeshellarg($sqlFile).' > '.escapeshellarg($tmpSql).' 2>/dev/null';
|
||||
exec($decompressCmd, $decompressOutput, $decompressCode);
|
||||
|
||||
if ($decompressCode !== 0) {
|
||||
$account->addLog("Warning: Failed to decompress database dump: {$fileName}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sqlToImport = $tmpSql;
|
||||
} elseif (str_ends_with($lower, '.sql.zst')) {
|
||||
$tmpSql = $extractDir.'/import_'.$account->id.'_'.$dbName.'_'.uniqid('', true).'.sql';
|
||||
$decompressCmd = 'zstd -dc '.escapeshellarg($sqlFile).' > '.escapeshellarg($tmpSql).' 2>/dev/null';
|
||||
exec($decompressCmd, $decompressOutput, $decompressCode);
|
||||
|
||||
if ($decompressCode !== 0) {
|
||||
$account->addLog("Warning: Failed to decompress database dump: {$fileName}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sqlToImport = $tmpSql;
|
||||
}
|
||||
|
||||
// Import data
|
||||
$cmd = 'mysql '.escapeshellarg($newDbName).' < '.escapeshellarg($sqlToImport).' 2>&1';
|
||||
exec($cmd, $importOutput, $importCode);
|
||||
|
||||
if ($importCode === 0) {
|
||||
$account->addLog("Imported database: {$newDbName}");
|
||||
} else {
|
||||
$account->addLog("Warning: Database created but import failed: {$newDbName}");
|
||||
}
|
||||
} finally {
|
||||
if ($tmpSql && file_exists($tmpSql)) {
|
||||
@unlink($tmpSql);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$account->addLog("Warning: Failed to create database: {$newDbName}");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
exec("rm -rf " . escapeshellarg($extractDir));
|
||||
exec('rm -rf '.escapeshellarg($extractDir));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +447,45 @@ class ImportProcessCommand extends Command
|
||||
$account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}");
|
||||
}
|
||||
|
||||
$account->addLog("Note: Email accounts must be recreated manually");
|
||||
$account->addLog('Note: Email accounts must be recreated manually');
|
||||
}
|
||||
|
||||
private function resolveBackupFullPath(ServerImport $import): ?string
|
||||
{
|
||||
$path = trim((string) ($import->backup_path ?? ''));
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/') && file_exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$localCandidate = Storage::disk('local')->path($path);
|
||||
if (file_exists($localCandidate)) {
|
||||
return $localCandidate;
|
||||
}
|
||||
|
||||
$backupCandidate = Storage::disk('backups')->path($path);
|
||||
if (file_exists($backupCandidate)) {
|
||||
return $backupCandidate;
|
||||
}
|
||||
|
||||
return file_exists($path) ? $path : null;
|
||||
}
|
||||
|
||||
private function getTarExtractCommandPrefix(string $archivePath): string
|
||||
{
|
||||
$archivePath = strtolower($archivePath);
|
||||
|
||||
if (str_ends_with($archivePath, '.tar.zst') || str_ends_with($archivePath, '.zst')) {
|
||||
return 'tar --zstd -xf';
|
||||
}
|
||||
|
||||
if (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
|
||||
return 'tar -xzf';
|
||||
}
|
||||
|
||||
return 'tar -xf';
|
||||
}
|
||||
}
|
||||
|
||||
779
app/Filament/Admin/Pages/DirectAdminMigration.php
Normal file
779
app/Filament/Admin/Pages/DirectAdminMigration.php
Normal file
@@ -0,0 +1,779 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Pages;
|
||||
|
||||
use App\Models\ServerImport;
|
||||
use App\Models\ServerImportAccount;
|
||||
use App\Services\Agent\AgentClient;
|
||||
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\FileUpload;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
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\Utilities\Get;
|
||||
use Filament\Schemas\Components\View;
|
||||
use Filament\Schemas\Components\Wizard;
|
||||
use Filament\Schemas\Components\Wizard\Step;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Url;
|
||||
|
||||
class DirectAdminMigration extends Page implements HasActions, HasForms
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
|
||||
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'directadmin-migration';
|
||||
|
||||
protected string $view = 'filament.admin.pages.directadmin-migration';
|
||||
|
||||
#[Url(as: 'directadmin-step')]
|
||||
public ?string $wizardStep = null;
|
||||
|
||||
public bool $step1Complete = false;
|
||||
|
||||
public ?int $importId = null;
|
||||
|
||||
public ?string $name = null;
|
||||
|
||||
public string $importMethod = 'remote_server'; // remote_server|backup_file
|
||||
|
||||
public ?string $remoteHost = null;
|
||||
|
||||
public int $remotePort = 2222;
|
||||
|
||||
public ?string $remoteUser = null;
|
||||
|
||||
public ?string $remotePassword = null;
|
||||
|
||||
public ?string $backupPath = null;
|
||||
|
||||
public ?string $backupFilePath = null;
|
||||
|
||||
public bool $importFiles = true;
|
||||
|
||||
public bool $importDatabases = true;
|
||||
|
||||
public bool $importEmails = true;
|
||||
|
||||
public bool $importSsl = true;
|
||||
|
||||
protected ?AgentClient $agent = null;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('DirectAdmin Migration');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('DirectAdmin Migration');
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return __('Migrate DirectAdmin accounts into 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 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 DirectAdmin backup archive (usually .tar.zst) to the server backups folder.'))
|
||||
->disk('backups')
|
||||
->directory('directadmin-migrations')
|
||||
->preserveFilenames()
|
||||
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
|
||||
TextInput::make('backupFilePath')
|
||||
->label(__('Backup File Path'))
|
||||
->placeholder('/var/backups/jabali/directadmin-migrations/user.tar.zst')
|
||||
->helperText(__('Use this if the backup file already exists on the server.'))
|
||||
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
|
||||
Text::make(__('Tip: Upload backups to /var/backups/jabali/directadmin-migrations/'))->color('gray')
|
||||
->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 or enter its full path.'));
|
||||
}
|
||||
|
||||
$backupFullPath = $this->resolveBackupFullPath($import->backup_path);
|
||||
if (! $backupFullPath) {
|
||||
throw new Exception(__('Backup file not found: :path', ['path' => $import->backup_path]));
|
||||
}
|
||||
} else {
|
||||
$remotePassword = $this->remotePassword;
|
||||
|
||||
if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) {
|
||||
$remotePassword = (string) $import->remote_password;
|
||||
}
|
||||
|
||||
if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) {
|
||||
throw new Exception(__('Please enter DirectAdmin host, port, username and password.'));
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->getAgent()->importDiscover(
|
||||
$import->id,
|
||||
'directadmin',
|
||||
$import->import_method,
|
||||
$backupFullPath,
|
||||
$import->remote_host,
|
||||
$import->remote_port ? (int) $import->remote_port : null,
|
||||
$import->remote_user,
|
||||
$remotePassword,
|
||||
);
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
throw new Exception((string) ($result['error'] ?? __('Discovery failed')));
|
||||
}
|
||||
|
||||
$accounts = $result['accounts'] ?? [];
|
||||
if (! is_array($accounts) || $accounts === []) {
|
||||
throw new Exception(__('No 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();
|
||||
}
|
||||
}
|
||||
|
||||
protected function resolveBackupFullPath(?string $path): ?string
|
||||
{
|
||||
$path = trim((string) ($path ?? ''));
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/') && file_exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$localCandidate = Storage::disk('local')->path($path);
|
||||
if (file_exists($localCandidate)) {
|
||||
return $localCandidate;
|
||||
}
|
||||
|
||||
$backupCandidate = Storage::disk('backups')->path($path);
|
||||
if (file_exists($backupCandidate)) {
|
||||
return $backupCandidate;
|
||||
}
|
||||
|
||||
return file_exists($path) ? $path : null;
|
||||
}
|
||||
|
||||
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->backupFilePath = 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') {
|
||||
$backupPath = filled($this->backupFilePath)
|
||||
? trim((string) $this->backupFilePath)
|
||||
: $this->backupPath;
|
||||
|
||||
$attributes['backup_path'] = $backupPath ?: null;
|
||||
$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');
|
||||
|
||||
$backupPath = is_string($import->backup_path) ? trim($import->backup_path) : null;
|
||||
if ($backupPath && str_starts_with($backupPath, '/')) {
|
||||
$this->backupFilePath = $backupPath;
|
||||
$this->backupPath = null;
|
||||
} else {
|
||||
$this->backupPath = $backupPath;
|
||||
$this->backupFilePath = null;
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
142
app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php
Normal file
142
app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Widgets;
|
||||
|
||||
use App\Models\ServerImport;
|
||||
use App\Models\ServerImportAccount;
|
||||
use App\Models\User;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\TextInputColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class DirectAdminAccountConfigTable extends Component implements HasActions, HasSchemas, HasTable
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $importId = null;
|
||||
|
||||
public function mount(?int $importId = null): void
|
||||
{
|
||||
$this->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<int>
|
||||
*/
|
||||
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<int, ServerImportAccount>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
155
app/Filament/Admin/Widgets/DirectAdminAccountsTable.php
Normal file
155
app/Filament/Admin/Widgets/DirectAdminAccountsTable.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Widgets;
|
||||
|
||||
use App\Models\ServerImport;
|
||||
use App\Models\ServerImportAccount;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||
use Filament\Support\Enums\IconSize;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class DirectAdminAccountsTable extends Component implements HasActions, HasSchemas, HasTable
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $importId = null;
|
||||
|
||||
public function mount(?int $importId = null): void
|
||||
{
|
||||
$this->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<int>
|
||||
*/
|
||||
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<int, ServerImportAccount>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
186
app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php
Normal file
186
app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Widgets;
|
||||
|
||||
use App\Models\ServerImport;
|
||||
use App\Models\ServerImportAccount;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Support\Enums\IconSize;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class DirectAdminMigrationStatusTable extends Component implements HasActions, HasSchemas, HasTable
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $importId = null;
|
||||
|
||||
public function mount(?int $importId = null): void
|
||||
{
|
||||
$this->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<int>
|
||||
*/
|
||||
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<int, ServerImportAccount>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ class CpanelMigration extends Page implements HasActions, HasForms
|
||||
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('cPanel Migration');
|
||||
|
||||
955
app/Filament/Jabali/Pages/DirectAdminMigration.php
Normal file
955
app/Filament/Jabali/Pages/DirectAdminMigration.php
Normal file
@@ -0,0 +1,955 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Jabali\Pages;
|
||||
|
||||
use App\Models\ServerImport;
|
||||
use App\Models\ServerImportAccount;
|
||||
use App\Services\Agent\AgentClient;
|
||||
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\FileUpload;
|
||||
use Filament\Forms\Components\Radio;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
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\Utilities\Get;
|
||||
use Filament\Schemas\Components\View;
|
||||
use Filament\Schemas\Components\Wizard;
|
||||
use Filament\Schemas\Components\Wizard\Step;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Url;
|
||||
|
||||
class DirectAdminMigration extends Page implements HasActions, HasForms
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
|
||||
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?int $navigationSort = 16;
|
||||
|
||||
protected static ?string $slug = 'directadmin-migration';
|
||||
|
||||
protected string $view = 'filament.jabali.pages.directadmin-migration';
|
||||
|
||||
#[Url(as: 'directadmin-step')]
|
||||
public ?string $wizardStep = null;
|
||||
|
||||
public bool $step1Complete = false;
|
||||
|
||||
public ?int $importId = null;
|
||||
|
||||
public string $importMethod = 'backup_file'; // remote_server|backup_file
|
||||
|
||||
public ?string $remoteHost = null;
|
||||
|
||||
public int $remotePort = 2222;
|
||||
|
||||
public ?string $remoteUser = null;
|
||||
|
||||
public ?string $remotePassword = null;
|
||||
|
||||
public ?string $localBackupPath = null;
|
||||
|
||||
public array $availableBackups = [];
|
||||
|
||||
public ?string $backupPath = null;
|
||||
|
||||
public bool $importFiles = true;
|
||||
|
||||
public bool $importDatabases = true;
|
||||
|
||||
public bool $importEmails = true;
|
||||
|
||||
public bool $importSsl = true;
|
||||
|
||||
protected ?AgentClient $agent = null;
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('DirectAdmin Migration');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('DirectAdmin Migration');
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return __('Migrate your DirectAdmin account into your Jabali account');
|
||||
}
|
||||
|
||||
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 the DirectAdmin migration wizard. Are you sure?'))
|
||||
->action('resetMigration'),
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->restoreFromSession();
|
||||
$this->restoreFromImport();
|
||||
|
||||
if ($this->importMethod === 'backup_file') {
|
||||
$this->loadLocalBackups();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedImportMethod(): void
|
||||
{
|
||||
$this->remoteHost = null;
|
||||
$this->remotePort = 2222;
|
||||
$this->remoteUser = null;
|
||||
$this->remotePassword = null;
|
||||
|
||||
$this->localBackupPath = null;
|
||||
$this->backupPath = null;
|
||||
$this->availableBackups = [];
|
||||
|
||||
if ($this->importMethod === 'backup_file') {
|
||||
$this->loadLocalBackups();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedLocalBackupPath(): void
|
||||
{
|
||||
if (! $this->localBackupPath) {
|
||||
$this->backupPath = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->selectLocalBackup();
|
||||
}
|
||||
|
||||
protected function getForms(): array
|
||||
{
|
||||
return ['migrationForm'];
|
||||
}
|
||||
|
||||
public function migrationForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Wizard::make([
|
||||
$this->getConnectStep(),
|
||||
$this->getConfigureStep(),
|
||||
$this->getMigrateStep(),
|
||||
])
|
||||
->persistStepInQueryString('directadmin-step'),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getConnectStep(): Step
|
||||
{
|
||||
return Step::make(__('Connect'))
|
||||
->id('connect')
|
||||
->icon('heroicon-o-link')
|
||||
->description(__('Connect to DirectAdmin or upload a backup'))
|
||||
->schema([
|
||||
Section::make(__('Source'))
|
||||
->description(__('For now, migration requires a DirectAdmin backup archive. Remote migration will be added next.'))
|
||||
->icon('heroicon-o-server')
|
||||
->schema([
|
||||
Radio::make('importMethod')
|
||||
->label(__('Import Method'))
|
||||
->options([
|
||||
'backup_file' => __('Backup File'),
|
||||
'remote_server' => __('Remote Server (Discovery only)'),
|
||||
])
|
||||
->default('backup_file')
|
||||
->live(),
|
||||
|
||||
Grid::make(['default' => 1, 'sm' => 2])
|
||||
->schema([
|
||||
TextInput::make('remoteHost')
|
||||
->label(__('Host'))
|
||||
->placeholder('directadmin.example.com')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
|
||||
TextInput::make('remotePort')
|
||||
->label(__('Port'))
|
||||
->numeric()
|
||||
->default(2222)
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
|
||||
TextInput::make('remoteUser')
|
||||
->label(__('Username'))
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
|
||||
TextInput::make('remotePassword')
|
||||
->label(__('Password'))
|
||||
->password()
|
||||
->revealable()
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
|
||||
]),
|
||||
|
||||
Section::make(__('Backup File'))
|
||||
->description(__('Upload your DirectAdmin backup archive to your backups folder, then select it here.'))
|
||||
->icon('heroicon-o-folder')
|
||||
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file')
|
||||
->headerActions([
|
||||
Action::make('uploadBackup')
|
||||
->label(__('Upload'))
|
||||
->icon('heroicon-o-arrow-up-tray')
|
||||
->color('gray')
|
||||
->modalHeading(__('Upload Backup'))
|
||||
->modalDescription(fn (): string => ($user = $this->getUser())
|
||||
? __('Upload a DirectAdmin backup archive into /home/:user/backups', ['user' => $user->username])
|
||||
: __('Upload a DirectAdmin backup archive into your backups folder'))
|
||||
->modalSubmitActionLabel(__('Upload'))
|
||||
->form([
|
||||
FileUpload::make('backup')
|
||||
->label(__('DirectAdmin Backup Archive'))
|
||||
->storeFiles(false)
|
||||
->required()
|
||||
->maxSize(512000) // 500MB in KB
|
||||
->helperText(__('Supported formats: .tar.zst, .tar.gz, .tgz (max 500MB via upload)')),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
try {
|
||||
$user = $this->getUser();
|
||||
if (! $user) {
|
||||
throw new Exception(__('You must be logged in.'));
|
||||
}
|
||||
|
||||
$file = $data['backup'] ?? null;
|
||||
if (! $file) {
|
||||
throw new Exception(__('Please select a backup file.'));
|
||||
}
|
||||
|
||||
$filename = (string) $file->getClientOriginalName();
|
||||
$filename = basename($filename);
|
||||
|
||||
if (! preg_match('/\\.(tar\\.zst|zst|tar\\.gz|tgz)$/i', $filename)) {
|
||||
throw new Exception(__('Backup must be a .zst, .tar.zst, .tar.gz or .tgz file.'));
|
||||
}
|
||||
|
||||
$maxBytes = 500 * 1024 * 1024;
|
||||
$fileSize = (int) ($file->getSize() ?? 0);
|
||||
if ($fileSize > $maxBytes) {
|
||||
throw new Exception(__('File too large for upload (max 500MB). Upload it via SSH/SFTP to /home/:user/backups.', [
|
||||
'user' => $user->username,
|
||||
]));
|
||||
}
|
||||
|
||||
// Ensure backups folder exists (mkdir will error if it already exists).
|
||||
try {
|
||||
$this->getAgent()->fileMkdir($user->username, 'backups');
|
||||
} catch (Exception $e) {
|
||||
if ($e->getMessage() !== 'Path already exists') {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage into the agent-allowed temp dir, then let the agent move it.
|
||||
$tmpDir = '/tmp/jabali-uploads';
|
||||
if (! is_dir($tmpDir)) {
|
||||
mkdir($tmpDir, 0700, true);
|
||||
chmod($tmpDir, 0700);
|
||||
} else {
|
||||
@chmod($tmpDir, 0700);
|
||||
}
|
||||
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
|
||||
$tmpPath = $tmpDir.'/'.uniqid('upload_', true).'_'.$safeName;
|
||||
|
||||
if (! @copy($file->getRealPath(), $tmpPath)) {
|
||||
throw new Exception(__('Failed to stage upload.'));
|
||||
}
|
||||
@chmod($tmpPath, 0600);
|
||||
|
||||
$result = $this->getAgent()->send('file.upload_temp', [
|
||||
'username' => $user->username,
|
||||
'path' => 'backups',
|
||||
'filename' => $safeName,
|
||||
'temp_path' => $tmpPath,
|
||||
]);
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
if (file_exists($tmpPath)) {
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
throw new Exception((string) ($result['error'] ?? __('Upload failed')));
|
||||
}
|
||||
|
||||
$this->loadLocalBackups();
|
||||
|
||||
$uploadedPath = $result['path'] ?? null;
|
||||
if (is_string($uploadedPath) && $uploadedPath !== '') {
|
||||
$this->localBackupPath = $uploadedPath;
|
||||
$this->selectLocalBackup();
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('Backup uploaded'))
|
||||
->body(__('Uploaded :name', ['name' => $safeName]))
|
||||
->success()
|
||||
->send();
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Upload failed'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Action::make('refreshLocalBackups')
|
||||
->label(__('Refresh'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->action('refreshLocalBackups'),
|
||||
])
|
||||
->schema([
|
||||
Select::make('localBackupPath')
|
||||
->label(__('Backup File'))
|
||||
->options(fn (): array => $this->getLocalBackupOptions())
|
||||
->searchable()
|
||||
->required(fn (Get $get): bool => $get('importMethod') === 'backup_file')
|
||||
->live(),
|
||||
Text::make(fn (): string => $this->backupPath
|
||||
? __('Selected file: :file', ['file' => basename($this->backupPath)])
|
||||
: __('No backup selected yet.'))
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => ($user = $this->getUser())
|
||||
? __('Upload the file to: /home/:user/backups', ['user' => $user->username])
|
||||
: __('Upload the file to your /home/<user>/backups folder.'))
|
||||
->color('gray'),
|
||||
Text::make(__('Supported formats: .tar.zst, .tar.gz, .tgz'))->color('gray'),
|
||||
Text::make(fn (): string => ($user = $this->getUser())
|
||||
? __('No backups found in /home/:user/backups. Upload a file there and click Refresh.', ['user' => $user->username])
|
||||
: __('No backups found.'))
|
||||
->color('gray')
|
||||
->visible(fn (): bool => empty($this->availableBackups)),
|
||||
]),
|
||||
|
||||
FormActions::make([
|
||||
Action::make('discoverAccount')
|
||||
->label(__('Discover Account'))
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->color('primary')
|
||||
->action('discoverAccount'),
|
||||
])->alignEnd(),
|
||||
]),
|
||||
|
||||
Section::make(__('Discovery'))
|
||||
->description(__('After discovery, you can choose what to import.'))
|
||||
->icon('heroicon-o-user')
|
||||
->schema([
|
||||
Text::make(__('Discovered account details will be used for migration.'))->color('gray'),
|
||||
]),
|
||||
])
|
||||
->afterValidation(function () {
|
||||
$import = $this->getImport();
|
||||
$hasAccounts = $import?->accounts()->exists() ?? false;
|
||||
|
||||
if (! $hasAccounts) {
|
||||
Notification::make()
|
||||
->title(__('No account discovered'))
|
||||
->body(__('Click "Discover Account" to continue.'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
throw new Exception(__('No account discovered'));
|
||||
}
|
||||
|
||||
$this->step1Complete = true;
|
||||
$this->saveToSession();
|
||||
});
|
||||
}
|
||||
|
||||
protected function getConfigureStep(): Step
|
||||
{
|
||||
return Step::make(__('Configure'))
|
||||
->id('configure')
|
||||
->icon('heroicon-o-cog')
|
||||
->description(__('Choose what to import'))
|
||||
->schema([
|
||||
Section::make(__('What to Import'))
|
||||
->description(__('Select which parts of your account to import.'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->schema([
|
||||
Grid::make(['default' => 1, 'sm' => 2])->schema([
|
||||
Checkbox::make('importFiles')
|
||||
->label(__('Website Files'))
|
||||
->helperText(__('Restore website files from the backup'))
|
||||
->default(true),
|
||||
Checkbox::make('importDatabases')
|
||||
->label(__('Databases'))
|
||||
->helperText(__('Restore MySQL databases and import dumps'))
|
||||
->default(true),
|
||||
Checkbox::make('importEmails')
|
||||
->label(__('Email'))
|
||||
->helperText(__('Create email domains and mailboxes (limited in Phase 1)'))
|
||||
->default(true),
|
||||
Checkbox::make('importSsl')
|
||||
->label(__('SSL'))
|
||||
->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)'))
|
||||
->default(true),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->afterValidation(function (): void {
|
||||
$import = $this->getImport();
|
||||
if (! $import) {
|
||||
throw new Exception(__('Import job not found'));
|
||||
}
|
||||
|
||||
$import->update([
|
||||
'import_options' => [
|
||||
'files' => $this->importFiles,
|
||||
'databases' => $this->importDatabases,
|
||||
'emails' => $this->importEmails,
|
||||
'ssl' => $this->importSsl,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->saveToSession();
|
||||
});
|
||||
}
|
||||
|
||||
protected function getMigrateStep(): Step
|
||||
{
|
||||
return Step::make(__('Migrate'))
|
||||
->id('migrate')
|
||||
->icon('heroicon-o-play')
|
||||
->description(__('Run the migration and watch progress'))
|
||||
->schema([
|
||||
FormActions::make([
|
||||
Action::make('startMigration')
|
||||
->label(__('Start Migration'))
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('Start Migration'))
|
||||
->modalDescription(__('This will import data into your Jabali account. Continue?'))
|
||||
->action('startMigration'),
|
||||
|
||||
Action::make('newMigration')
|
||||
->label(__('New Migration'))
|
||||
->icon('heroicon-o-plus')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed')
|
||||
->action('resetMigration'),
|
||||
])->alignEnd(),
|
||||
|
||||
Section::make(__('Import Status'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->schema([
|
||||
View::make('filament.jabali.pages.directadmin-migration-status-table'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function discoverAccount(): void
|
||||
{
|
||||
try {
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
throw new Exception(__('You must be logged in.'));
|
||||
}
|
||||
|
||||
$import = $this->upsertImportForDiscovery();
|
||||
|
||||
$backupFullPath = null;
|
||||
$remotePassword = null;
|
||||
|
||||
if ($this->importMethod === 'backup_file') {
|
||||
if (! $import->backup_path) {
|
||||
throw new Exception(__('Please select a DirectAdmin backup archive.'));
|
||||
}
|
||||
|
||||
$backupFullPath = $this->resolveBackupFullPath($import->backup_path);
|
||||
if (! $backupFullPath) {
|
||||
throw new Exception(__('Backup file not found: :path', ['path' => $import->backup_path]));
|
||||
}
|
||||
} else {
|
||||
$remotePassword = $this->remotePassword;
|
||||
|
||||
if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) {
|
||||
$remotePassword = (string) $import->remote_password;
|
||||
}
|
||||
|
||||
if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) {
|
||||
throw new Exception(__('Please enter DirectAdmin host, port, username and password.'));
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->getAgent()->importDiscover(
|
||||
$import->id,
|
||||
'directadmin',
|
||||
$import->import_method,
|
||||
$backupFullPath,
|
||||
$import->remote_host,
|
||||
$import->remote_port ? (int) $import->remote_port : null,
|
||||
$import->remote_user,
|
||||
$remotePassword,
|
||||
);
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
throw new Exception((string) ($result['error'] ?? __('Discovery failed')));
|
||||
}
|
||||
|
||||
$accounts = $result['accounts'] ?? [];
|
||||
if (! is_array($accounts) || $accounts === []) {
|
||||
throw new Exception(__('No account was discovered.'));
|
||||
}
|
||||
|
||||
$account = null;
|
||||
if (count($accounts) === 1) {
|
||||
$account = $accounts[0];
|
||||
} else {
|
||||
// Prefer matching the provided username if multiple accounts are returned.
|
||||
foreach ($accounts as $candidate) {
|
||||
if (! is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (($candidate['username'] ?? null) === $this->remoteUser) {
|
||||
$account = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($account)) {
|
||||
throw new Exception(__('Multiple accounts were discovered. Please upload a single-user backup archive.'));
|
||||
}
|
||||
|
||||
$sourceUsername = trim((string) ($account['username'] ?? ''));
|
||||
if ($sourceUsername === '') {
|
||||
throw new Exception(__('Discovered account is missing a username.'));
|
||||
}
|
||||
|
||||
$import->accounts()->delete();
|
||||
|
||||
$record = ServerImportAccount::create([
|
||||
'server_import_id' => $import->id,
|
||||
'source_username' => $sourceUsername,
|
||||
'target_username' => $user->username,
|
||||
'email' => (string) ($account['email'] ?? ''),
|
||||
'main_domain' => (string) ($account['main_domain'] ?? ''),
|
||||
'addon_domains' => $account['addon_domains'] ?? [],
|
||||
'subdomains' => $account['subdomains'] ?? [],
|
||||
'databases' => $account['databases'] ?? [],
|
||||
'email_accounts' => $account['email_accounts'] ?? [],
|
||||
'disk_usage' => (int) ($account['disk_usage'] ?? 0),
|
||||
'status' => 'pending',
|
||||
'progress' => 0,
|
||||
'current_task' => null,
|
||||
'import_log' => [],
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
$import->update([
|
||||
'discovered_accounts' => [$account],
|
||||
'selected_accounts' => [$record->id],
|
||||
'status' => 'ready',
|
||||
'progress' => 0,
|
||||
'current_task' => null,
|
||||
'errors' => [],
|
||||
]);
|
||||
|
||||
$this->importId = $import->id;
|
||||
$this->step1Complete = true;
|
||||
$this->saveToSession();
|
||||
|
||||
$this->dispatch('directadmin-self-status-updated');
|
||||
|
||||
Notification::make()
|
||||
->title(__('Account discovered'))
|
||||
->body(__('Ready to migrate into your Jabali account (:username).', ['username' => $user->username]))
|
||||
->success()
|
||||
->send();
|
||||
} catch (Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('Discovery failed'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function startMigration(): void
|
||||
{
|
||||
$import = $this->getImport();
|
||||
if (! $import) {
|
||||
Notification::make()
|
||||
->title(__('Import job not found'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$selected = $import->selected_accounts ?? [];
|
||||
if (! is_array($selected) || $selected === []) {
|
||||
Notification::make()
|
||||
->title(__('No account selected'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($import->import_method === 'remote_server') {
|
||||
Notification::make()
|
||||
->title(__('Remote DirectAdmin import is not available yet'))
|
||||
->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$import->update([
|
||||
'status' => 'importing',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $this->getAgent()->importStart($import->id);
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
Notification::make()
|
||||
->title(__('Failed to start migration'))
|
||||
->body((string) ($result['error'] ?? __('Unknown error')))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('Migration started'))
|
||||
->body(__('Import process has started in the background.'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->dispatch('directadmin-self-status-updated');
|
||||
}
|
||||
|
||||
public function resetMigration(): void
|
||||
{
|
||||
if ($this->importId) {
|
||||
ServerImport::whereKey($this->importId)->delete();
|
||||
}
|
||||
|
||||
session()->forget('directadmin_self_migration.import_id');
|
||||
|
||||
$this->wizardStep = null;
|
||||
$this->step1Complete = false;
|
||||
$this->importId = null;
|
||||
$this->importMethod = 'backup_file';
|
||||
$this->remoteHost = null;
|
||||
$this->remotePort = 2222;
|
||||
$this->remoteUser = null;
|
||||
$this->remotePassword = null;
|
||||
$this->localBackupPath = null;
|
||||
$this->availableBackups = [];
|
||||
$this->backupPath = null;
|
||||
$this->importFiles = true;
|
||||
$this->importDatabases = true;
|
||||
$this->importEmails = true;
|
||||
$this->importSsl = true;
|
||||
}
|
||||
|
||||
protected function getAgent(): AgentClient
|
||||
{
|
||||
return $this->agent ??= new AgentClient;
|
||||
}
|
||||
|
||||
protected function getUser()
|
||||
{
|
||||
return Auth::user();
|
||||
}
|
||||
|
||||
protected function loadLocalBackups(): void
|
||||
{
|
||||
$this->availableBackups = [];
|
||||
|
||||
$user = $this->getUser();
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->getAgent()->send('file.list', [
|
||||
'username' => $user->username,
|
||||
'path' => 'backups',
|
||||
]);
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
$this->getAgent()->send('file.mkdir', [
|
||||
'username' => $user->username,
|
||||
'path' => 'backups',
|
||||
]);
|
||||
|
||||
$result = $this->getAgent()->send('file.list', [
|
||||
'username' => $user->username,
|
||||
'path' => 'backups',
|
||||
]);
|
||||
|
||||
if (! ($result['success'] ?? false)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$items = $result['items'] ?? [];
|
||||
foreach ($items as $item) {
|
||||
if (($item['is_dir'] ?? false) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($item['name'] ?? '');
|
||||
if (! preg_match('/\\.(tar\\.zst|zst|tar\\.gz|tgz)$/i', $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->availableBackups[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshLocalBackups(): void
|
||||
{
|
||||
$this->loadLocalBackups();
|
||||
|
||||
Notification::make()
|
||||
->title(__('Backup list refreshed'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function getLocalBackupOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($this->availableBackups as $item) {
|
||||
$path = $item['path'] ?? null;
|
||||
$name = $item['name'] ?? null;
|
||||
if (! $path || ! $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = $this->formatBytes((int) ($item['size'] ?? 0));
|
||||
$options[$path] = "{$name} ({$size})";
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
protected function selectLocalBackup(): void
|
||||
{
|
||||
$user = $this->getUser();
|
||||
if (! $user || ! $this->localBackupPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
$info = $this->getAgent()->send('file.info', [
|
||||
'username' => $user->username,
|
||||
'path' => $this->localBackupPath,
|
||||
]);
|
||||
|
||||
if (! ($info['success'] ?? false)) {
|
||||
Notification::make()
|
||||
->title(__('Backup file not found'))
|
||||
->body($info['error'] ?? __('Unable to read backup file'))
|
||||
->danger()
|
||||
->send();
|
||||
$this->backupPath = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$details = $info['info'] ?? [];
|
||||
if (! ($details['is_file'] ?? false)) {
|
||||
Notification::make()
|
||||
->title(__('Invalid backup selection'))
|
||||
->body(__('Please select a backup file'))
|
||||
->warning()
|
||||
->send();
|
||||
$this->backupPath = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->backupPath = "/home/{$user->username}/{$this->localBackupPath}";
|
||||
|
||||
Notification::make()
|
||||
->title(__('Backup selected'))
|
||||
->body(__('Selected :name (:size)', [
|
||||
'name' => $details['name'] ?? basename($this->backupPath),
|
||||
'size' => $this->formatBytes((int) ($details['size'] ?? 0)),
|
||||
]))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2).' GB';
|
||||
}
|
||||
if ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2).' MB';
|
||||
}
|
||||
if ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 2).' KB';
|
||||
}
|
||||
|
||||
return $bytes.' B';
|
||||
}
|
||||
|
||||
protected function resolveBackupFullPath(?string $path): ?string
|
||||
{
|
||||
$path = trim((string) ($path ?? ''));
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/') && file_exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$localCandidate = Storage::disk('local')->path($path);
|
||||
if (file_exists($localCandidate)) {
|
||||
return $localCandidate;
|
||||
}
|
||||
|
||||
$backupCandidate = Storage::disk('backups')->path($path);
|
||||
if (file_exists($backupCandidate)) {
|
||||
return $backupCandidate;
|
||||
}
|
||||
|
||||
return file_exists($path) ? $path : null;
|
||||
}
|
||||
|
||||
protected function getImport(): ?ServerImport
|
||||
{
|
||||
if (! $this->importId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ServerImport::with('accounts')->find($this->importId);
|
||||
}
|
||||
|
||||
protected function upsertImportForDiscovery(): ServerImport
|
||||
{
|
||||
$user = Auth::user();
|
||||
$name = $user ? ('DirectAdmin Import - '.$user->username.' - '.now()->format('Y-m-d H:i')) : ('DirectAdmin Import '.now()->format('Y-m-d H:i'));
|
||||
|
||||
$attributes = [
|
||||
'name' => $name,
|
||||
'source_type' => 'directadmin',
|
||||
'import_method' => $this->importMethod,
|
||||
'import_options' => [
|
||||
'files' => $this->importFiles,
|
||||
'databases' => $this->importDatabases,
|
||||
'emails' => $this->importEmails,
|
||||
'ssl' => $this->importSsl,
|
||||
],
|
||||
'status' => 'discovering',
|
||||
'progress' => 0,
|
||||
'current_task' => null,
|
||||
];
|
||||
|
||||
if ($this->importMethod === 'backup_file') {
|
||||
$attributes['backup_path'] = $this->backupPath;
|
||||
$attributes['remote_host'] = null;
|
||||
$attributes['remote_port'] = null;
|
||||
$attributes['remote_user'] = null;
|
||||
} else {
|
||||
$attributes['backup_path'] = null;
|
||||
$attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null;
|
||||
$attributes['remote_port'] = $this->remotePort;
|
||||
$attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null;
|
||||
|
||||
if (filled($this->remotePassword)) {
|
||||
$attributes['remote_password'] = $this->remotePassword;
|
||||
}
|
||||
}
|
||||
|
||||
$import = $this->importId ? ServerImport::find($this->importId) : null;
|
||||
|
||||
if ($import) {
|
||||
$import->update($attributes);
|
||||
} else {
|
||||
$import = ServerImport::create($attributes);
|
||||
$this->importId = $import->id;
|
||||
}
|
||||
|
||||
$this->saveToSession();
|
||||
|
||||
return $import->fresh();
|
||||
}
|
||||
|
||||
protected function saveToSession(): void
|
||||
{
|
||||
if ($this->importId) {
|
||||
session()->put('directadmin_self_migration.import_id', $this->importId);
|
||||
}
|
||||
|
||||
session()->save();
|
||||
}
|
||||
|
||||
protected function restoreFromSession(): void
|
||||
{
|
||||
$this->importId = session('directadmin_self_migration.import_id');
|
||||
}
|
||||
|
||||
protected function restoreFromImport(): void
|
||||
{
|
||||
$import = $this->getImport();
|
||||
if (! $import) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->importMethod = (string) ($import->import_method ?? 'backup_file');
|
||||
$this->backupPath = $import->backup_path;
|
||||
if ($this->backupPath && ($user = $this->getUser())) {
|
||||
$prefix = "/home/{$user->username}/";
|
||||
if (str_starts_with($this->backupPath, $prefix)) {
|
||||
$this->localBackupPath = ltrim(substr($this->backupPath, strlen($prefix)), '/');
|
||||
}
|
||||
}
|
||||
$this->remoteHost = $import->remote_host;
|
||||
$this->remotePort = (int) ($import->remote_port ?? 2222);
|
||||
$this->remoteUser = $import->remote_user;
|
||||
|
||||
$options = $import->import_options ?? [];
|
||||
if (is_array($options)) {
|
||||
$this->importFiles = (bool) ($options['files'] ?? true);
|
||||
$this->importDatabases = (bool) ($options['databases'] ?? true);
|
||||
$this->importEmails = (bool) ($options['emails'] ?? true);
|
||||
$this->importSsl = (bool) ($options['ssl'] ?? true);
|
||||
}
|
||||
|
||||
$this->step1Complete = $import->accounts()->exists();
|
||||
}
|
||||
}
|
||||
85
app/Filament/Jabali/Pages/Migration.php
Normal file
85
app/Filament/Jabali/Pages/Migration.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Jabali\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\View;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Livewire\Attributes\Url;
|
||||
|
||||
class Migration extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
|
||||
|
||||
protected static ?string $navigationLabel = null;
|
||||
|
||||
protected static ?int $navigationSort = 15;
|
||||
|
||||
protected string $view = 'filament.jabali.pages.migration';
|
||||
|
||||
#[Url(as: 'migration')]
|
||||
public string $activeTab = 'cpanel';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Migration');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('Migration');
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return __('Migrate a cPanel or DirectAdmin account into your Jabali account');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! in_array($this->activeTab, ['cpanel', 'directadmin'], true)) {
|
||||
$this->activeTab = 'cpanel';
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedActiveTab(string $activeTab): void
|
||||
{
|
||||
if (! in_array($activeTab, ['cpanel', 'directadmin'], true)) {
|
||||
$this->activeTab = 'cpanel';
|
||||
}
|
||||
}
|
||||
|
||||
protected function getForms(): array
|
||||
{
|
||||
return ['migrationForm'];
|
||||
}
|
||||
|
||||
public function migrationForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Tabs::make(__('Migration Type'))
|
||||
->livewireProperty('activeTab')
|
||||
->tabs([
|
||||
'cpanel' => Tabs\Tab::make(__('cPanel Migration'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->schema([
|
||||
View::make('filament.jabali.pages.migration-cpanel-tab'),
|
||||
]),
|
||||
'directadmin' => Tabs\Tab::make(__('DirectAdmin Migration'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->schema([
|
||||
View::make('filament.jabali.pages.migration-directadmin-tab'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
170
app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php
Normal file
170
app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Jabali\Widgets;
|
||||
|
||||
use App\Models\ServerImport;
|
||||
use App\Models\ServerImportAccount;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||
use Filament\Support\Enums\FontWeight;
|
||||
use Filament\Support\Enums\IconSize;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class DirectAdminMigrationStatusTable extends Component implements HasActions, HasSchemas, HasTable
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
use InteractsWithTable;
|
||||
|
||||
public ?int $importId = null;
|
||||
|
||||
public function mount(?int $importId = null): void
|
||||
{
|
||||
$this->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<int, ServerImportAccount>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,5 +185,53 @@ class BackupSchedule extends Model
|
||||
return $timezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for active schedules.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for due schedules.
|
||||
*/
|
||||
public function scopeDue($query)
|
||||
{
|
||||
return $query->active()
|
||||
->where(function ($q) {
|
||||
$q->whereNull('next_run_at')
|
||||
->orWhere('next_run_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for user schedules.
|
||||
*/
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for server backup schedules.
|
||||
*/
|
||||
public function scopeServerBackups($query)
|
||||
{
|
||||
return $query->where('is_server_backup', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last status color for UI.
|
||||
*/
|
||||
public function getLastStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->last_status) {
|
||||
'success' => 'success',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class User extends Authenticatable implements FilamentUser
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
|
||||
}
|
||||
|
||||
@@ -100,32 +100,37 @@ class User extends Authenticatable implements FilamentUser
|
||||
$masterUser = $user->username.'_admin';
|
||||
|
||||
try {
|
||||
// Use credentials from environment variables
|
||||
$mysqli = new \mysqli(
|
||||
config('database.connections.mysql.host', 'localhost'),
|
||||
config('database.connections.mysql.username'),
|
||||
config('database.connections.mysql.password')
|
||||
);
|
||||
if (class_exists(\mysqli::class)) {
|
||||
// Use credentials from environment variables
|
||||
$mysqli = new \mysqli(
|
||||
config('database.connections.mysql.host', 'localhost'),
|
||||
config('database.connections.mysql.username'),
|
||||
config('database.connections.mysql.password')
|
||||
);
|
||||
|
||||
if (! $mysqli->connect_error) {
|
||||
// Use prepared statement to prevent SQL injection
|
||||
// MySQL doesn't support prepared statements for DROP USER,
|
||||
// so we validate the username format strictly
|
||||
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
|
||||
throw new \Exception('Invalid MySQL username format');
|
||||
if (! $mysqli->connect_error) {
|
||||
// Use prepared statement to prevent SQL injection
|
||||
// MySQL doesn't support prepared statements for DROP USER,
|
||||
// so we validate the username format strictly
|
||||
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
|
||||
throw new \Exception('Invalid MySQL username format');
|
||||
}
|
||||
|
||||
// Escape the username as an additional safety measure
|
||||
$escapedUser = $mysqli->real_escape_string($masterUser);
|
||||
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
|
||||
$mysqli->close();
|
||||
}
|
||||
|
||||
// Escape the username as an additional safety measure
|
||||
$escapedUser = $mysqli->real_escape_string($masterUser);
|
||||
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
|
||||
$mysqli->close();
|
||||
}
|
||||
|
||||
// Delete stored credentials
|
||||
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Failed to delete master MySQL user: '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Failed to delete stored MySQL credentials: '.$e->getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
115
bin/jabali-agent
115
bin/jabali-agent
@@ -12681,23 +12681,30 @@ function discoverDirectAdminBackup(string $backupPath, string $extractDir): arra
|
||||
|
||||
$accounts = [];
|
||||
|
||||
$tarArgs = '';
|
||||
if (preg_match('/\\.(tar\\.zst|zst)$/i', $backupPath)) {
|
||||
$tarArgs = '--zstd';
|
||||
} elseif (preg_match('/\\.(tar\\.gz|tgz)$/i', $backupPath)) {
|
||||
$tarArgs = '-I pigz';
|
||||
}
|
||||
|
||||
// List archive contents
|
||||
$cmd = "tar -I pigz -tf " . escapeshellarg($backupPath) . " 2>/dev/null | head -500";
|
||||
$cmd = "tar $tarArgs -tf " . escapeshellarg($backupPath) . " 2>/dev/null | head -500";
|
||||
exec($cmd, $fileList, $code);
|
||||
|
||||
if ($code !== 0) {
|
||||
throw new Exception('Failed to read backup file. Make sure it is a valid tar.gz archive.');
|
||||
throw new Exception('Failed to read backup file. Make sure it is a valid DirectAdmin archive (.tar.zst, .tar.gz, .tgz).');
|
||||
}
|
||||
|
||||
$fileListStr = implode("\n", $fileList);
|
||||
|
||||
// DirectAdmin backup structure: backup/user.conf, domains/, databases/
|
||||
// Or: user.username.tar.gz containing the above
|
||||
// Or: user.username.tar.zst (or .tar.gz) containing the above
|
||||
|
||||
// Check for user.conf file (single user backup)
|
||||
if (preg_match('/(backup\/)?user\.conf/', $fileListStr)) {
|
||||
// Extract user.conf and domains list
|
||||
$extractCmd = "tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " --wildcards 'backup/user.conf' 'user.conf' 'domains/*' 'backup/domains/*' 2>/dev/null";
|
||||
$extractCmd = "tar $tarArgs -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " --wildcards 'backup/user.conf' 'user.conf' 'domains/*' 'backup/domains/*' 2>/dev/null";
|
||||
exec($extractCmd);
|
||||
|
||||
$userConf = null;
|
||||
@@ -12718,18 +12725,25 @@ function discoverDirectAdminBackup(string $backupPath, string $extractDir): arra
|
||||
|
||||
// Check for multiple user backups (full server backup)
|
||||
foreach ($fileList as $file) {
|
||||
if (preg_match('/user\.([a-z0-9_]+)\.tar\.gz/i', $file, $matches)) {
|
||||
if (preg_match('/user\\.([a-z0-9_]+)\\.(?:tar\\.zst|zst|tar\\.gz|tgz)$/i', $file, $matches)) {
|
||||
$username = $matches[1];
|
||||
|
||||
// Extract just this user's backup
|
||||
$innerBackup = $file;
|
||||
exec("tar -I pigz -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " " . escapeshellarg($innerBackup) . " 2>/dev/null");
|
||||
exec("tar $tarArgs -xf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . " " . escapeshellarg($innerBackup) . " 2>/dev/null");
|
||||
|
||||
$innerPath = "$extractDir/$innerBackup";
|
||||
if (file_exists($innerPath)) {
|
||||
$innerTarArgs = '';
|
||||
if (preg_match('/\\.(tar\\.zst|zst)$/i', $innerPath)) {
|
||||
$innerTarArgs = '--zstd';
|
||||
} elseif (preg_match('/\\.(tar\\.gz|tgz)$/i', $innerPath)) {
|
||||
$innerTarArgs = '-I pigz';
|
||||
}
|
||||
|
||||
$innerExtract = "$extractDir/$username";
|
||||
mkdir($innerExtract, 0755, true);
|
||||
exec("tar -I pigz -xf " . escapeshellarg($innerPath) . " -C " . escapeshellarg($innerExtract) . " --wildcards 'backup/user.conf' 'user.conf' 2>/dev/null");
|
||||
exec("tar $innerTarArgs -xf " . escapeshellarg($innerPath) . " -C " . escapeshellarg($innerExtract) . " --wildcards 'backup/user.conf' 'user.conf' 2>/dev/null");
|
||||
|
||||
$userConf = glob("$innerExtract/*/user.conf")[0] ?? glob("$innerExtract/user.conf")[0] ?? null;
|
||||
if ($userConf) {
|
||||
@@ -12901,6 +12915,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 +12977,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 +12995,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];
|
||||
@@ -12976,7 +13021,7 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string
|
||||
*/
|
||||
function importStart(array $params): array
|
||||
{
|
||||
$importId = $params['import_id'] ?? 0;
|
||||
$importId = (int) ($params['import_id'] ?? 0);
|
||||
|
||||
logger("Starting import process for import ID: $importId");
|
||||
|
||||
@@ -12990,7 +13035,7 @@ function importStart(array $params): array
|
||||
}
|
||||
|
||||
// Dispatch a Laravel job to handle the import in the background
|
||||
$cmd = "cd /var/www/jabali && php artisan import:process " . escapeshellarg($importId) . " > /dev/null 2>&1 &";
|
||||
$cmd = "cd /var/www/jabali && php artisan import:process " . escapeshellarg((string) $importId) . " > /dev/null 2>&1 &";
|
||||
exec($cmd);
|
||||
|
||||
return ['success' => true, 'message' => 'Import process started'];
|
||||
|
||||
@@ -37,6 +37,13 @@ return [
|
||||
'root' => '/tmp',
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
// Server-wide backups folder (created by install.sh)
|
||||
'backups' => [
|
||||
'driver' => 'local',
|
||||
'root' => env('JABALI_BACKUPS_ROOT', '/var/backups/jabali'),
|
||||
'throw' => false,
|
||||
],
|
||||
],
|
||||
|
||||
'links' => [
|
||||
|
||||
277
docs/architecture/directadmin-migration-blueprint.md
Normal file
277
docs/architecture/directadmin-migration-blueprint.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# DirectAdmin Migration Blueprint
|
||||
|
||||
This blueprint describes how Jabali Panel should migrate accounts from a remote
|
||||
DirectAdmin server into Jabali.
|
||||
|
||||
It is written to match Jabali's current architecture:
|
||||
- Laravel 12 + Filament v5 + Livewire v4 UI.
|
||||
- Privileged agent (`bin/jabali-agent`) for root-level operations.
|
||||
- Long-running work via jobs/queue with resumable logs.
|
||||
|
||||
## Goals
|
||||
|
||||
- Support connecting to a remote DirectAdmin server (host, port, credentials).
|
||||
- Support multi-account migrations (admin-initiated).
|
||||
- Support user self-migration (user-initiated, scoped to their Jabali account).
|
||||
- Migrate websites, databases, email, and SSL.
|
||||
- Provide clear progress, per-account logs, and safe retries.
|
||||
|
||||
## Non-Goals (Initial Scope)
|
||||
|
||||
- Reseller plans and quota mapping (can be added later).
|
||||
- DNS zone migrations from DirectAdmin (optional later).
|
||||
- Password migration for website logins and mailboxes (not possible in general).
|
||||
|
||||
## UX Overview
|
||||
|
||||
Jabali already has:
|
||||
- Admin migration entry: `jabali-admin/migration` (tabs page).
|
||||
- User migration entry: `jabali-panel/cpanel-migration` (cPanel only today).
|
||||
|
||||
DirectAdmin migration should be added to both panels:
|
||||
- Admin: new migration tab alongside cPanel and WHM.
|
||||
- User: new self-migration page similar to user cPanel migration.
|
||||
|
||||
The UI should use Filament native components (Wizard, Sections, Tables), and
|
||||
should not embed custom HTML/CSS.
|
||||
|
||||
## Admin Flow (Multi-Account)
|
||||
|
||||
### Step 1: Connect
|
||||
|
||||
Inputs:
|
||||
- Hostname or IP
|
||||
- Port (default DirectAdmin: 2222)
|
||||
- Auth:
|
||||
- Username + password (initial)
|
||||
- Optional future: API token
|
||||
- SSL verify toggle (default on, allow off for lab servers)
|
||||
|
||||
Actions:
|
||||
- Test connection
|
||||
- Discover users/accounts
|
||||
|
||||
Output:
|
||||
- Server metadata (DirectAdmin version if available)
|
||||
- Discovered accounts summary
|
||||
|
||||
### Step 2: Select Accounts
|
||||
|
||||
Show a table of discovered accounts:
|
||||
- Source username
|
||||
- Main domain
|
||||
- Email contact
|
||||
- Disk usage (if provided)
|
||||
|
||||
Selection:
|
||||
- Multi-select accounts for import.
|
||||
|
||||
Per-account mapping:
|
||||
- Target Jabali username (editable, default = source username)
|
||||
- Target user email (editable)
|
||||
- Conflict indicators (existing Jabali user, existing domains)
|
||||
|
||||
### Step 3: Choose What To Import
|
||||
|
||||
Toggles:
|
||||
- Files
|
||||
- Databases
|
||||
- Email
|
||||
- SSL
|
||||
|
||||
Optional safety toggles:
|
||||
- Skip existing domains
|
||||
- Skip existing databases
|
||||
- Re-issue SSL via Let's Encrypt when custom SSL is missing or invalid
|
||||
|
||||
### Step 4: Run Migration
|
||||
|
||||
Execution runs as a background job batch:
|
||||
- Per-account status: pending, running, completed, failed, skipped.
|
||||
- Per-account logs (timestamps + messages).
|
||||
- Global log for the import job.
|
||||
|
||||
Controls:
|
||||
- Cancel import (best-effort stop at safe boundaries).
|
||||
- Retry failed accounts.
|
||||
|
||||
## User Flow (Self-Migration)
|
||||
|
||||
User self-migration is a guided flow to import a single DirectAdmin account into
|
||||
the currently authenticated Jabali user.
|
||||
|
||||
### Step 1: Connect
|
||||
|
||||
Inputs:
|
||||
- DirectAdmin hostname/IP and port
|
||||
- DirectAdmin username + password
|
||||
|
||||
Actions:
|
||||
- Test connection
|
||||
- Verify the DirectAdmin user is accessible
|
||||
|
||||
### Step 2: Choose What To Import
|
||||
|
||||
Toggles:
|
||||
- Files
|
||||
- Databases
|
||||
- Email
|
||||
- SSL
|
||||
|
||||
### Step 3: Run Migration
|
||||
|
||||
Show:
|
||||
- Live progress
|
||||
- Logs
|
||||
- Final summary
|
||||
|
||||
Scope and enforcement:
|
||||
- Target Jabali user is fixed to the authenticated user.
|
||||
- Import must refuse to touch domains that do not belong to the user.
|
||||
|
||||
## Data Model (Proposed)
|
||||
|
||||
Jabali already has:
|
||||
- `server_imports` and `server_import_accounts`.
|
||||
|
||||
To support DirectAdmin remote migration properly, add:
|
||||
- `server_imports.created_by_user_id` (nullable, for admin-created vs user-created).
|
||||
- `server_imports.target_user_id` (nullable, for admin selecting a target user, optional).
|
||||
- `server_import_accounts.backup_path` (nullable, per-account backup archive path when remote).
|
||||
- `server_import_accounts.ssl_items` (json, optional, discovered SSL material per domain).
|
||||
- `server_import_accounts.mail_items` (json, optional, discovered mailboxes and domains).
|
||||
|
||||
Also update `server_imports.import_options` to include:
|
||||
- `files`, `databases`, `emails`, `ssl`.
|
||||
|
||||
## Import Pipeline (High-Level)
|
||||
|
||||
### Phase A: Discovery
|
||||
|
||||
Purpose:
|
||||
- Validate credentials and enumerate accounts to import.
|
||||
|
||||
Implementation notes:
|
||||
- Jabali agent already supports discovery for DirectAdmin remote via:
|
||||
- `CMD_API_SHOW_ALL_USERS`
|
||||
- `CMD_API_SHOW_USER_CONFIG`
|
||||
|
||||
### Phase B: Backup Creation and Download (Remote Method)
|
||||
|
||||
Problem:
|
||||
- The current import processor only imports from local backup archives.
|
||||
|
||||
Solution:
|
||||
- For `import_method=remote_server`, create and download a DirectAdmin backup
|
||||
per selected account to `storage/app/private/imports/...`.
|
||||
|
||||
Implementation choices:
|
||||
- Run this phase in a queued job to avoid request timeouts.
|
||||
- Download must stream to disk, not to memory.
|
||||
- Store paths per account (`server_import_accounts.backup_path`).
|
||||
|
||||
### Phase C: Analyze Backup (Optional But Recommended)
|
||||
|
||||
Purpose:
|
||||
- Extract account metadata (domains, DB dumps, email list, SSL presence).
|
||||
- Show a preview before the destructive restore phase.
|
||||
|
||||
Implementation notes:
|
||||
- Reuse the existing agent discovery for backup files (`import.discover`).
|
||||
- Extend discovery to detect:
|
||||
- Mailbox domains and mailbox names
|
||||
- SSL certificate files per domain (if present)
|
||||
|
||||
### Phase D: Restore Into Jabali
|
||||
|
||||
For each account:
|
||||
1. Create or map Jabali user.
|
||||
2. Create domains.
|
||||
3. Restore website files into the correct document roots.
|
||||
4. Restore databases and import dumps.
|
||||
5. Restore email domains and mailboxes, then copy Maildir data.
|
||||
6. Restore SSL certificates (or issue Let's Encrypt if configured).
|
||||
|
||||
All steps must write logs to both:
|
||||
- `server_imports.import_log`
|
||||
- `server_import_accounts.import_log`
|
||||
|
||||
## Email Migration (Requirements)
|
||||
|
||||
Minimum requirements:
|
||||
- Create mail domains in Jabali for the migrated domains.
|
||||
- Create mailboxes for the discovered mailbox usernames.
|
||||
- Import Maildir content (messages, folders) into the new mailboxes.
|
||||
|
||||
Recommended approach:
|
||||
- Use Jabali agent functions:
|
||||
- `email.enable_domain`
|
||||
- `email.mailbox_create`
|
||||
- `email.sync_maps` and `email.reload_services` when needed
|
||||
|
||||
Notes:
|
||||
- DirectAdmin backups can include hashed mailbox passwords, which are not
|
||||
directly reusable for Dovecot. Use new random passwords and provide a
|
||||
"reset passwords" output list to the admin or user.
|
||||
|
||||
## SSL Migration (Requirements)
|
||||
|
||||
Minimum requirements:
|
||||
- If custom SSL material is present in the DirectAdmin backup, install it for
|
||||
each domain in Jabali.
|
||||
|
||||
Recommended approach:
|
||||
- Extract PEM certificate, private key, and optional chain from the backup.
|
||||
- Use Jabali agent function `ssl.install` per domain.
|
||||
|
||||
Fallback:
|
||||
- If SSL material is missing or invalid, allow issuing Let's Encrypt via
|
||||
`ssl.issue` after DNS and vhost are ready.
|
||||
|
||||
## Multi-Account Support
|
||||
|
||||
Admin migration must allow selecting multiple DirectAdmin users and migrating
|
||||
them in one batch.
|
||||
|
||||
Execution model:
|
||||
- One `server_imports` record per batch.
|
||||
- One `server_import_accounts` record per DirectAdmin user.
|
||||
- Independent status and retry per account.
|
||||
|
||||
## Security and Compliance
|
||||
|
||||
- Store remote passwords and tokens encrypted at rest (already supported for
|
||||
`server_imports.remote_password` and `server_imports.remote_api_token`).
|
||||
- Never write raw credentials into logs.
|
||||
- Provide a "forget credentials" action after the migration completes.
|
||||
- Rate-limit connection tests and discovery to reduce abuse.
|
||||
|
||||
## Observability
|
||||
|
||||
- Show per-account progress and current step in the UI.
|
||||
- Write a compact global log and a detailed per-account log.
|
||||
- On failures, capture enough context to troubleshoot:
|
||||
- Which step failed
|
||||
- The relevant domain or database name
|
||||
- A short error string without secrets
|
||||
|
||||
## Implementation Phases (Suggested)
|
||||
|
||||
Phase 1:
|
||||
- Admin: add DirectAdmin migration tab (UI skeleton).
|
||||
- User: add DirectAdmin self-migration page (UI skeleton).
|
||||
- Wire UI to the existing `server_imports` discovery (remote and backup-file).
|
||||
|
||||
Phase 2:
|
||||
- Implement remote backup creation and download.
|
||||
- Store per-account backup paths.
|
||||
- Make `import:process` support `remote_server` by using downloaded archives.
|
||||
|
||||
Phase 3:
|
||||
- Implement email restore (mail domains, mailboxes, Maildir copy).
|
||||
- Implement SSL restore (custom cert install, LE fallback).
|
||||
|
||||
Phase 4:
|
||||
- Add tests (discovery, permissions, per-account import state).
|
||||
- Add docs and screenshots for the new pages/tabs.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Jabali Documentation Index
|
||||
|
||||
Last updated: 2026-02-09
|
||||
Last updated: 2026-02-10
|
||||
|
||||
## Top-Level Docs
|
||||
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
|
||||
@@ -13,6 +13,7 @@ Last updated: 2026-02-09
|
||||
## Docs Folder
|
||||
- /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
|
||||
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
|
||||
- /var/www/jabali/docs/architecture/directadmin-migration-blueprint.md - Blueprint for migrating DirectAdmin accounts into Jabali.
|
||||
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
|
||||
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
|
||||
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Documentation Summary (Jabali Panel)
|
||||
|
||||
Last updated: 2026-02-09
|
||||
Last updated: 2026-02-10
|
||||
|
||||
## Product Overview
|
||||
Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity.
|
||||
@@ -53,5 +53,6 @@ mcp-docs-server exposes README, AGENT docs, and changelog through MCP tools for
|
||||
|
||||
## Miscellaneous Docs
|
||||
- Screenshot regeneration script: tests/take-screenshots.cjs.
|
||||
- DirectAdmin migration blueprint: docs/architecture/directadmin-migration-blueprint.md.
|
||||
- Policies: resources/markdown/policy.md and resources/markdown/terms.md are placeholders.
|
||||
- WordPress plugin: resources/wordpress/jabali-cache/readme.txt documents the Jabali Cache plugin.
|
||||
|
||||
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
||||
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
|
||||
fi
|
||||
JABALI_VERSION="${JABALI_VERSION:-0.9-rc60}"
|
||||
JABALI_VERSION="${JABALI_VERSION:-0.9-rc61}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
@@ -2945,8 +2945,9 @@ EOF
|
||||
mkdir -p /var/backups/jabali
|
||||
mkdir -p /var/backups/jabali/cpanel-migrations
|
||||
mkdir -p /var/backups/jabali/whm-migrations
|
||||
mkdir -p /var/backups/jabali/directadmin-migrations
|
||||
chown -R $JABALI_USER:$JABALI_USER /var/backups/jabali
|
||||
chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations
|
||||
chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations /var/backups/jabali/directadmin-migrations
|
||||
|
||||
log "Jabali Panel setup complete"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountConfigTable::class, [
|
||||
'importId' => $this->importId,
|
||||
], key('directadmin-account-config-table-' . ($this->importId ?? 'new')))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountsTable::class, [
|
||||
'importId' => $this->importId,
|
||||
], key('directadmin-accounts-table-' . ($this->importId ?? 'new')))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@livewire(\App\Filament\Admin\Widgets\DirectAdminMigrationStatusTable::class, [
|
||||
'importId' => $this->importId,
|
||||
], key('directadmin-migration-status-table-' . ($this->importId ?? 'new')))
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->migrationForm }}
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@livewire(\App\Filament\Admin\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
@livewire(\App\Filament\Jabali\Widgets\DirectAdminMigrationStatusTable::class, [
|
||||
'importId' => $this->importId,
|
||||
], key('directadmin-self-migration-status-table-' . ($this->importId ?? 'new')))
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->migrationForm }}
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@livewire(\App\Filament\Jabali\Pages\CpanelMigration::class, [], key('migration-cpanel'))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@livewire(\App\Filament\Jabali\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->migrationForm }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -1,33 +1,53 @@
|
||||
@php
|
||||
$jabaliVersion = '1.0.1';
|
||||
$jabaliVersion = 'unknown';
|
||||
$versionFile = base_path('VERSION');
|
||||
if (file_exists($versionFile)) {
|
||||
$content = file_get_contents($versionFile);
|
||||
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
|
||||
if (preg_match('/^VERSION=(.+)$/m', $content, $matches)) {
|
||||
$jabaliVersion = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<style>
|
||||
.dark .jabali-footer-logo { filter: invert(1) brightness(2); }
|
||||
</style>
|
||||
<div style="border-top: 1px solid rgba(128,128,128,0.1); padding: 20px 24px; margin-top: auto;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<img src="{{ asset('images/jabali_logo.svg') }}" alt="Jabali" style="height: 32px; width: 32px;" class="jabali-footer-logo">
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 15px;" class="text-gray-700 dark:text-gray-200">Jabali Panel</div>
|
||||
<div style="font-size: 12px;" class="text-gray-500 dark:text-gray-400">Web Hosting Control Panel</div>
|
||||
|
||||
<footer class="mt-auto border-t border-gray-200/20 px-6 py-5 dark:border-white/10">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
src="{{ asset('images/jabali_logo.svg') }}"
|
||||
alt="{{ __('Jabali') }}"
|
||||
class="h-8 w-8 dark:filter dark:invert dark:brightness-200"
|
||||
>
|
||||
|
||||
<div class="leading-tight">
|
||||
<div class="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{{ __('Jabali Panel') }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('Web Hosting Control Panel') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 16px; font-size: 13px;" class="text-gray-500 dark:text-gray-400">
|
||||
<a href="https://github.com/shukiv/jabali-panel" target="_blank" style="display: flex; align-items: center; gap: 6px; text-decoration: none;" class="text-gray-500 dark:text-gray-400 hover:text-blue-500">
|
||||
<svg style="width: 16px; height: 16px;" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
<span style="color: rgba(128,128,128,0.3);">•</span>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<x-filament::link
|
||||
tag="a"
|
||||
href="https://github.com/shukiv/jabali-panel"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
{{ __('GitHub') }}
|
||||
</x-filament::link>
|
||||
|
||||
<span class="text-gray-400/50 dark:text-white/20">•</span>
|
||||
|
||||
<span>© {{ date('Y') }} Jabali</span>
|
||||
<span style="background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600;">v{{ $jabaliVersion }}</span>
|
||||
|
||||
<x-filament::badge size="sm" color="gray">
|
||||
v{{ $jabaliVersion }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -27,7 +27,7 @@ class ServerChartsWidgetTest extends TestCase
|
||||
$this->assertArrayHasKey('disk', $history);
|
||||
$this->assertArrayHasKey('/', $history['disk']);
|
||||
$this->assertArrayHasKey('/boot', $history['disk']);
|
||||
$this->assertCount(5, $history['labels']);
|
||||
$this->assertCount(30, $history['labels']);
|
||||
$this->assertCount(count($history['labels']), $history['swap']);
|
||||
$this->assertCount(count($history['labels']), $history['iowait']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user