Compare commits
24 Commits
v0.9-rc54
...
5d502699ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d502699ea | |||
| 967df591d6 | |||
| 2bdf7395fc | |||
| c4acf0b658 | |||
| ed5e3f2bda | |||
| 070e46cf77 | |||
| a566a2ae64 | |||
| 1e66f43d4e | |||
| 443b05a677 | |||
| 13685615cb | |||
| e7920366d7 | |||
| 3fa6399b27 | |||
| e22d73eba5 | |||
| a9f8670224 | |||
| 386c759e70 | |||
| c1599f5dd1 | |||
| 6064de6c81 | |||
| f7902105de | |||
| b049d338d8 | |||
| 8573d96719 | |||
| 800e07d2ba | |||
| c6f5b6cab8 | |||
|
|
8acc55a799 | ||
|
|
a5742a3156 |
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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
AGENT.md
8
AGENT.md
@@ -620,7 +620,15 @@ All administrative actions are logged to the `audit_logs` table.
|
||||
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
|
||||
- **MUST be responsive** - All pages must work on mobile, tablet, and desktop
|
||||
|
||||
### Warning Banners
|
||||
|
||||
- Use Filament `Section::make()` for warning banners (no raw HTML).
|
||||
- Always set `->icon('heroicon-o-exclamation-triangle')` and `->iconColor('warning')`.
|
||||
- Keep banners non-collapsible: `->collapsed(false)->collapsible(false)`.
|
||||
- Put the full message in `->description()` and keep the heading short.
|
||||
|
||||
### Allowed Components
|
||||
|
||||
Use these Filament native components exclusively:
|
||||
|
||||
| Category | Components |
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@ use Filament\Actions\Contracts\HasActions;
|
||||
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\EmbeddedTable;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Text;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
@@ -50,6 +53,13 @@ class Dashboard extends Page implements HasActions, HasForms
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! DnsSetting::get('onboarding_completed', false)) {
|
||||
$this->defaultAction = 'onboarding';
|
||||
}
|
||||
}
|
||||
|
||||
protected function getForms(): array
|
||||
{
|
||||
return [
|
||||
@@ -78,27 +88,86 @@ class Dashboard extends Page implements HasActions, HasForms
|
||||
->color('gray')
|
||||
->action(fn () => $this->redirect(request()->url())),
|
||||
|
||||
Action::make('onboarding')
|
||||
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
|
||||
->label(__('Setup Wizard'))
|
||||
->icon('heroicon-o-sparkles')
|
||||
->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
|
||||
|
||||
->modalHeading(__('Welcome to Jabali!'))
|
||||
->modalDescription(__('Let\'s get your server control panel set up.'))
|
||||
->modalWidth('md')
|
||||
->modalWidth('2xl')
|
||||
->fillForm(function (): array {
|
||||
$savedRecipients = trim((string) DnsSetting::get('admin_email_recipients', ''));
|
||||
$savedPrimaryEmail = $savedRecipients === '' ? '' : trim(explode(',', $savedRecipients)[0]);
|
||||
|
||||
return [
|
||||
'admin_email' => $savedPrimaryEmail,
|
||||
];
|
||||
})
|
||||
->form([
|
||||
Section::make(__('Next Steps'))
|
||||
->description(__('Here is a quick setup path to get your first site online.'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact()
|
||||
->schema([
|
||||
Grid::make(['default' => 1, 'md' => 2])
|
||||
->schema([
|
||||
Section::make(__('1. Configure Server Settings'))
|
||||
->description(__('Set hostname, DNS, email, storage, and PHP defaults.'))
|
||||
->icon('heroicon-o-cog-6-tooth')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
Section::make(__('2. Create a Hosting Package'))
|
||||
->description(__('Define limits and features for your plans.'))
|
||||
->icon('heroicon-o-cube')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
Section::make(__('3. Create a User'))
|
||||
->description(__('Assign the hosting package and set credentials.'))
|
||||
->icon('heroicon-o-user-plus')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
Section::make(__('4. Add a Domain'))
|
||||
->description(__('Issue SSL and deploy your site files.'))
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
]),
|
||||
Text::make(__('Optional: review Services and Server Status to confirm everything is healthy.'))
|
||||
->color('gray'),
|
||||
]),
|
||||
TextInput::make('admin_email')
|
||||
->label(__('Your Email Address'))
|
||||
->helperText(__('Enter your email to receive important server notifications.'))
|
||||
->email()
|
||||
->placeholder(__('admin@example.com')),
|
||||
])
|
||||
->modalSubmitActionLabel(__('Get Started'))
|
||||
->modalSubmitActionLabel(__('Save and close'))
|
||||
->action(function (array $data): void {
|
||||
if (! empty($data['admin_email'])) {
|
||||
DnsSetting::set('admin_email_recipients', $data['admin_email']);
|
||||
$adminEmail = trim((string) ($data['admin_email'] ?? ''));
|
||||
|
||||
if ($adminEmail !== '') {
|
||||
DnsSetting::set('admin_email_recipients', $adminEmail);
|
||||
}
|
||||
|
||||
DnsSetting::set('onboarding_completed', '1');
|
||||
DnsSetting::clearCache();
|
||||
|
||||
Notification::make()
|
||||
->title(__('Setup saved'))
|
||||
->body(__('Your notification email has been updated.'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
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'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -575,7 +575,9 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
->visible(fn () => $this->fail2banRunning)
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('Disable Fail2ban'))
|
||||
->modalDescription(__('Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
|
||||
->modalIcon('heroicon-o-exclamation-triangle')
|
||||
->modalIconColor('warning')
|
||||
->modalDescription(__('Warning: Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
|
||||
->action('disableFail2ban'),
|
||||
])
|
||||
->schema([
|
||||
@@ -665,9 +667,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
||||
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
|
||||
->size('sm')
|
||||
->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav())
|
||||
->requiresConfirmation(fn () => ! $this->clamavRunning)
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn () => $this->clamavRunning ? __('Disable ClamAV') : __('Enable ClamAV'))
|
||||
->modalIcon('heroicon-o-exclamation-triangle')
|
||||
->modalIconColor('warning')
|
||||
->modalDescription(fn () => $this->clamavRunning
|
||||
? __('ClamAV will be stopped and disabled. You can re-enable it later.')
|
||||
? __('Warning: This will stop and disable ClamAV. You can re-enable it later.')
|
||||
: __('Starting ClamAV daemon uses ~500MB RAM. Continue?')),
|
||||
FormAction::make('updateSignatures')
|
||||
->label(__('Update Signatures'))
|
||||
|
||||
@@ -692,17 +692,17 @@ class ServerSettings extends Page implements HasActions, HasForms
|
||||
protected function databaseTabContent(): array
|
||||
{
|
||||
return [
|
||||
Section::make(__('Warning: Changing database settings can impact performance or cause outages'))
|
||||
->description(__('Apply changes only if you understand their effects, and prefer doing so during maintenance windows.'))
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->iconColor('warning')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
Section::make(__('Database Tuning'))
|
||||
->description(__('Adjust MariaDB/MySQL global variables.'))
|
||||
->icon('heroicon-o-circle-stack')
|
||||
->schema([
|
||||
Placeholder::make('database_tuning_warning')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg bg-warning-500/10 p-4 text-sm text-warning-700 dark:text-warning-400">'.
|
||||
'<strong>'.__('Warning:').'</strong> '.
|
||||
__('Changing database settings can impact performance or cause outages. Apply changes only if you understand their effects, and prefer doing so during maintenance windows.').
|
||||
'</div>'
|
||||
)),
|
||||
EmbeddedTable::make(DatabaseTuningTable::class),
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -208,7 +208,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
|
||||
->visible(fn (array $record): bool => $record['is_active'])
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('Stop Service'))
|
||||
->modalDescription(fn (array $record): string => __('Are you sure you want to stop :service? This may affect running websites and services.', ['service' => $record['name']]))
|
||||
->modalIcon('heroicon-o-exclamation-triangle')
|
||||
->modalIconColor('warning')
|
||||
->modalDescription(fn (array $record): string => __('Warning: This will stop :service and may affect running websites and services. Are you sure you want to continue?', ['service' => $record['name']]))
|
||||
->modalSubmitActionLabel(__('Stop Service'))
|
||||
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
|
||||
Action::make('restart')
|
||||
@@ -236,7 +238,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
|
||||
->visible(fn (array $record): bool => $record['is_enabled'])
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('Disable Service'))
|
||||
->modalDescription(fn (array $record): string => __("Are you sure you want to disable :service? It won't start automatically on boot.", ['service' => $record['name']]))
|
||||
->modalIcon('heroicon-o-exclamation-triangle')
|
||||
->modalIconColor('warning')
|
||||
->modalDescription(fn (array $record): string => __('Warning: This will disable :service and it will not start automatically on boot. Are you sure you want to continue?', ['service' => $record['name']]))
|
||||
->modalSubmitActionLabel(__('Disable Service'))
|
||||
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
|
||||
])
|
||||
|
||||
30
app/Filament/Admin/Pages/Support.php
Normal file
30
app/Filament/Admin/Pages/Support.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Admin\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
class Support extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
|
||||
|
||||
protected static ?int $navigationSort = 23;
|
||||
|
||||
protected static ?string $slug = 'support';
|
||||
|
||||
protected string $view = 'filament.admin.pages.support';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Support');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('Support');
|
||||
}
|
||||
}
|
||||
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');
|
||||
|
||||
@@ -9,8 +9,13 @@ use App\Filament\Jabali\Widgets\DomainsWidget;
|
||||
use App\Filament\Jabali\Widgets\MailboxesWidget;
|
||||
use App\Filament\Jabali\Widgets\RecentBackupsWidget;
|
||||
use App\Filament\Jabali\Widgets\StatsOverview;
|
||||
use App\Models\DnsSetting;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Dashboard as BaseDashboard;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Text;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class Dashboard extends BaseDashboard
|
||||
@@ -41,6 +46,75 @@ class Dashboard extends BaseDashboard
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! DnsSetting::get('user_onboarding_completed_'.(string) Auth::id(), false)) {
|
||||
$this->defaultAction = 'onboarding';
|
||||
}
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
|
||||
->label(__('Setup Wizard'))
|
||||
->icon('heroicon-o-sparkles')
|
||||
|
||||
->modalHeading(__('Welcome to Jabali!'))
|
||||
->modalDescription(__('Here is a quick path to launch your first site.'))
|
||||
->modalWidth('2xl')
|
||||
->form([
|
||||
Section::make(__('Next Steps'))
|
||||
->description(__('Follow these steps to get online quickly.'))
|
||||
->icon('heroicon-o-check-circle')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact()
|
||||
->schema([
|
||||
Grid::make(['default' => 1, 'md' => 2])
|
||||
->schema([
|
||||
Section::make(__('1. Add a Domain'))
|
||||
->description(__('Point your DNS to this server or update nameservers.'))
|
||||
->icon('heroicon-o-globe-alt')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
Section::make(__('2. Issue SSL'))
|
||||
->description(__('Enable HTTPS for your site with SSL certificates.'))
|
||||
->icon('heroicon-o-lock-closed')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
Section::make(__('3. Upload or Install'))
|
||||
->description(__('Upload files or install WordPress to deploy your site.'))
|
||||
->icon('heroicon-o-arrow-up-tray')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
Section::make(__('4. Create Email & Databases'))
|
||||
->description(__('Set up mailboxes and databases for your app.'))
|
||||
->icon('heroicon-o-envelope')
|
||||
->iconColor('info')
|
||||
->collapsed(false)
|
||||
->collapsible(false)
|
||||
->compact(),
|
||||
]),
|
||||
Text::make(__('Optional: configure backups, cron jobs, and SSH keys for day-to-day operations.'))
|
||||
->color('gray'),
|
||||
]),
|
||||
])
|
||||
->modalSubmitActionLabel(__("Don't show again"))
|
||||
->action(function (): void {
|
||||
DnsSetting::set('user_onboarding_completed_'.(string) Auth::id(), '1');
|
||||
DnsSetting::clearCache();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\View;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
@@ -1017,21 +1018,26 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
||||
->label(__('Domain'))
|
||||
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
||||
->required()
|
||||
->searchable(),
|
||||
->searchable()
|
||||
->live()
|
||||
->live(),
|
||||
TextInput::make('local_part')
|
||||
->label(__('Email Address'))
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
||||
->maxLength(64)
|
||||
->helperText(__('The part before the @ symbol')),
|
||||
TextInput::make('name')
|
||||
->label(__('Display Name'))
|
||||
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->maxLength(255),
|
||||
TextInput::make('password')
|
||||
->label(__('Password'))
|
||||
->password()
|
||||
->revealable()
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->minLength(8)
|
||||
->rules([
|
||||
'regex:/[a-z]/', // lowercase
|
||||
@@ -1063,6 +1069,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
||||
TextInput::make('quota_mb')
|
||||
->label(__('Quota (MB)'))
|
||||
->numeric()
|
||||
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->default(1024)
|
||||
->minValue(100)
|
||||
->maxValue(10240)
|
||||
@@ -1236,16 +1243,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
||||
->label(__('Domain'))
|
||||
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
||||
->required()
|
||||
->searchable(),
|
||||
->searchable()
|
||||
->live(),
|
||||
TextInput::make('local_part')
|
||||
->label(__('Email Address'))
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
||||
->maxLength(64)
|
||||
->helperText(__('The part before the @ symbol')),
|
||||
TextInput::make('destinations')
|
||||
->label(__('Forward To'))
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||
->helperText(__('Comma-separated email addresses to forward to')),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
|
||||
@@ -60,7 +60,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return 'File Manager';
|
||||
return __('File Manager');
|
||||
}
|
||||
|
||||
public function getAgent(): AgentClient
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Jabali\Pages;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Filament\Jabali\Widgets\ActivityLogTable;
|
||||
use App\Services\Agent\AgentClient;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@@ -16,6 +16,7 @@ use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\EmbeddedTable;
|
||||
use Filament\Schemas\Components\View;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
@@ -119,7 +120,7 @@ class Logs extends Page implements HasActions, HasForms
|
||||
'activity' => Tab::make(__('Activity Log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->schema([
|
||||
View::make('filament.jabali.pages.logs-tab-activity'),
|
||||
EmbeddedTable::make(ActivityLogTable::class),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -227,15 +228,6 @@ class Logs extends Page implements HasActions, HasForms
|
||||
->send();
|
||||
}
|
||||
|
||||
public function getActivityLogs()
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('user_id', Auth::id())
|
||||
->latest()
|
||||
->limit(50)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function generateStats(): void
|
||||
{
|
||||
if (! $this->selectedDomain) {
|
||||
|
||||
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'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
app/Filament/Jabali/Pages/Support.php
Normal file
30
app/Filament/Jabali/Pages/Support.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Jabali\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
class Support extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
|
||||
|
||||
protected static ?int $navigationSort = 23;
|
||||
|
||||
protected static ?string $slug = 'support';
|
||||
|
||||
protected string $view = 'filament.jabali.pages.support';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return __('Support');
|
||||
}
|
||||
|
||||
public function getTitle(): string|Htmlable
|
||||
{
|
||||
return __('Support');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Jabali\Pages;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\DnsRecord;
|
||||
use App\Models\DnsSetting;
|
||||
use App\Models\MysqlCredential;
|
||||
use App\Services\Agent\AgentClient;
|
||||
use BackedEnum;
|
||||
@@ -22,6 +24,7 @@ use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ViewColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
@@ -204,16 +207,46 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
->modalDescription(__('This will create a copy of your site for testing.'))
|
||||
->modalIcon('heroicon-o-document-duplicate')
|
||||
->modalIconColor('info')
|
||||
->form([
|
||||
TextInput::make('staging_subdomain')
|
||||
->label(__('Staging Subdomain'))
|
||||
->prefix('staging-')
|
||||
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
|
||||
->default('test')
|
||||
->required()
|
||||
->alphaNum(),
|
||||
])
|
||||
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
|
||||
->form(function (array $record): array {
|
||||
$sourceDomain = strtolower(trim((string) ($record['domain'] ?? '')));
|
||||
$ownedDomainOptions = $this->getOwnedDomainOptions([$sourceDomain]);
|
||||
|
||||
return [
|
||||
Select::make('staging_target_type')
|
||||
->label(__('Target Type'))
|
||||
->options([
|
||||
'subdomain' => __('Subdomain (on source domain)'),
|
||||
'domain' => __('Existing domain from my list'),
|
||||
])
|
||||
->default('subdomain')
|
||||
->required()
|
||||
->native(false)
|
||||
->live(),
|
||||
TextInput::make('staging_subdomain')
|
||||
->label(__('Subdomain'))
|
||||
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
|
||||
->default('test')
|
||||
->required(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
|
||||
->visible(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
|
||||
->regex('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/')
|
||||
->helperText(__('Example: "test" creates test.:domain', ['domain' => $record['domain'] ?? ''])),
|
||||
Select::make('staging_domain')
|
||||
->label(__('Target Domain'))
|
||||
->options($ownedDomainOptions)
|
||||
->required(fn (Get $get): bool => $get('staging_target_type') === 'domain')
|
||||
->visible(fn (Get $get): bool => $get('staging_target_type') === 'domain')
|
||||
->searchable()
|
||||
->native(false)
|
||||
->placeholder(__('Select a domain...'))
|
||||
->helperText(__('Use one of your existing domains as the staging target.')),
|
||||
];
|
||||
})
|
||||
->action(fn (array $data, array $record) => $this->createStaging(
|
||||
$record['id'],
|
||||
(string) ($data['staging_subdomain'] ?? ''),
|
||||
(string) ($data['staging_domain'] ?? ''),
|
||||
(string) ($data['staging_target_type'] ?? 'subdomain')
|
||||
)),
|
||||
Action::make('pushStaging')
|
||||
->label(__('Push to Production'))
|
||||
->icon('heroicon-o-arrow-up-tray')
|
||||
@@ -258,6 +291,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
);
|
||||
|
||||
if ($result['success'] ?? false) {
|
||||
if (($record['is_staging'] ?? false)) {
|
||||
$this->removeStagingDnsRecords($record);
|
||||
}
|
||||
|
||||
if (($record['is_staging'] ?? false) && ! empty($record['domain'])) {
|
||||
Domain::query()
|
||||
->where('user_id', Auth::id())
|
||||
->where('domain', (string) $record['domain'])
|
||||
->delete();
|
||||
}
|
||||
|
||||
// Delete screenshot if exists
|
||||
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
|
||||
if (file_exists($screenshotPath)) {
|
||||
@@ -448,24 +492,29 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
->options($domainOptions)
|
||||
->required()
|
||||
->searchable()
|
||||
->live()
|
||||
->placeholder(__('Select a domain...'))
|
||||
->helperText(__('The domain where WordPress will be installed')),
|
||||
Toggle::make('use_www')
|
||||
->label(__('Use www prefix'))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->helperText(__('Install on www.domain.com instead of domain.com'))
|
||||
->default(false),
|
||||
TextInput::make('path')
|
||||
->label(__('Directory (optional)'))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->placeholder(__('Leave empty to install in root'))
|
||||
->helperText(__('e.g., "blog" to install at domain.com/blog')),
|
||||
TextInput::make('site_title')
|
||||
->label(__('Site Title'))
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->default(__('My WordPress Site'))
|
||||
->helperText(__('The name of your WordPress site')),
|
||||
TextInput::make('admin_user')
|
||||
->label(__('Admin Username'))
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->default('admin')
|
||||
->alphaNum()
|
||||
->helperText(__('Username for the WordPress admin account')),
|
||||
@@ -473,7 +522,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
->label(__('Admin Password'))
|
||||
->password()
|
||||
->revealable()
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->default(fn () => $this->generateSecurePassword())
|
||||
->minLength(8)
|
||||
->rules([
|
||||
@@ -504,7 +554,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
|
||||
TextInput::make('admin_email')
|
||||
->label(__('Admin Email'))
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->email()
|
||||
->default(Auth::user()->email ?? '')
|
||||
->helperText(__('Email address for the WordPress admin account')),
|
||||
@@ -538,14 +589,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
])
|
||||
->default('en_US')
|
||||
->searchable()
|
||||
->required()
|
||||
->required(fn (Get $get): bool => filled($get('domain')))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->helperText(__('Default language for WordPress admin and content')),
|
||||
Toggle::make('enable_cache')
|
||||
->label(__('Enable Jabali Cache'))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->helperText(__('Install Redis object caching for better performance'))
|
||||
->default(true),
|
||||
Toggle::make('enable_auto_update')
|
||||
->label(__('Enable Auto-Updates'))
|
||||
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||
->helperText(__('Automatically update WordPress, plugins, and themes'))
|
||||
->default(false),
|
||||
])
|
||||
@@ -883,22 +937,75 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
}
|
||||
}
|
||||
|
||||
public function createStaging(string $siteId, string $subdomain): void
|
||||
public function createStaging(string $siteId, string $subdomain, string $targetDomain = '', string $targetType = 'subdomain'): void
|
||||
{
|
||||
try {
|
||||
$sourceSite = collect($this->sites)->firstWhere('id', $siteId);
|
||||
$sourceDomain = (string) ($sourceSite['domain'] ?? '');
|
||||
$normalizedSourceDomain = strtolower(trim($sourceDomain));
|
||||
|
||||
$agentPayload = [
|
||||
'username' => $this->getUsername(),
|
||||
'site_id' => $siteId,
|
||||
];
|
||||
|
||||
if ($targetType === 'domain') {
|
||||
$targetDomain = strtolower(trim($targetDomain));
|
||||
if ($targetDomain === '') {
|
||||
throw new Exception(__('Please choose a target domain.'));
|
||||
}
|
||||
if ($targetDomain === $normalizedSourceDomain) {
|
||||
throw new Exception(__('The staging domain must be different from the source domain.'));
|
||||
}
|
||||
if (! $this->isOwnedDomain($targetDomain)) {
|
||||
throw new Exception(__('The selected domain is not in your domain list.'));
|
||||
}
|
||||
$agentPayload['target_domain'] = $targetDomain;
|
||||
} else {
|
||||
$subdomain = strtolower(trim($subdomain));
|
||||
if ($subdomain === '') {
|
||||
throw new Exception(__('Please enter a subdomain.'));
|
||||
}
|
||||
if (! preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $subdomain)) {
|
||||
throw new Exception(__('Subdomain can contain only letters, numbers, and hyphens.'));
|
||||
}
|
||||
$agentPayload['subdomain'] = $subdomain;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('Creating Staging Environment...'))
|
||||
->body(__('This may take several minutes.'))
|
||||
->info()
|
||||
->send();
|
||||
|
||||
$result = $this->getAgent()->send('wp.create_staging', [
|
||||
'username' => $this->getUsername(),
|
||||
'site_id' => $siteId,
|
||||
'subdomain' => 'staging-'.$subdomain,
|
||||
]);
|
||||
$result = $this->getAgent()->send('wp.create_staging', $agentPayload);
|
||||
|
||||
if ($result['success'] ?? false) {
|
||||
$stagingDomain = (string) ($result['staging_domain'] ?? '');
|
||||
if ($stagingDomain !== '') {
|
||||
Domain::firstOrCreate(
|
||||
[
|
||||
'user_id' => Auth::id(),
|
||||
'domain' => $stagingDomain,
|
||||
],
|
||||
[
|
||||
'document_root' => '/home/'.$this->getUsername().'/domains/'.$stagingDomain.'/public_html',
|
||||
'is_active' => true,
|
||||
'ssl_enabled' => false,
|
||||
'directory_index' => 'index.php index.html',
|
||||
'page_cache_enabled' => false,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
$sourceDomain !== ''
|
||||
&& $stagingDomain !== ''
|
||||
&& str_ends_with(strtolower($stagingDomain), '.'.strtolower($sourceDomain))
|
||||
) {
|
||||
$this->ensureStagingDnsRecords($sourceDomain, $stagingDomain);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('Staging Environment Created'))
|
||||
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
|
||||
@@ -1455,4 +1562,193 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
||||
|
||||
return file_exists(storage_path('app/public/screenshots/'.$filename));
|
||||
}
|
||||
|
||||
protected function ensureStagingDnsRecords(string $sourceDomainName, string $stagingDomainName): void
|
||||
{
|
||||
$sourceDomain = Domain::query()
|
||||
->where('user_id', Auth::id())
|
||||
->where('domain', $sourceDomainName)
|
||||
->first();
|
||||
|
||||
if (! $sourceDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
|
||||
if ($label === null || $label === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = DnsSetting::getAll();
|
||||
$defaultTtl = (int) ($settings['default_ttl'] ?? 3600);
|
||||
|
||||
$defaultIpv4 = $sourceDomain->ip_address
|
||||
?: ($settings['default_ip'] ?? trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? '')) ?: '127.0.0.1');
|
||||
|
||||
DnsRecord::query()->updateOrCreate(
|
||||
[
|
||||
'domain_id' => $sourceDomain->id,
|
||||
'name' => $label,
|
||||
'type' => 'A',
|
||||
],
|
||||
[
|
||||
'content' => $defaultIpv4,
|
||||
'ttl' => $defaultTtl,
|
||||
'priority' => null,
|
||||
]
|
||||
);
|
||||
|
||||
$defaultIpv6 = $sourceDomain->ipv6_address ?: ($settings['default_ipv6'] ?? null);
|
||||
if (! empty($defaultIpv6)) {
|
||||
DnsRecord::query()->updateOrCreate(
|
||||
[
|
||||
'domain_id' => $sourceDomain->id,
|
||||
'name' => $label,
|
||||
'type' => 'AAAA',
|
||||
],
|
||||
[
|
||||
'content' => $defaultIpv6,
|
||||
'ttl' => $defaultTtl,
|
||||
'priority' => null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->syncDnsZone($sourceDomain, $settings);
|
||||
} catch (Exception) {
|
||||
// Keep staging creation successful even if DNS sync is temporarily unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
protected function removeStagingDnsRecords(array $stagingSite): void
|
||||
{
|
||||
$stagingDomainName = strtolower(trim((string) ($stagingSite['domain'] ?? '')));
|
||||
if ($stagingDomainName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceDomainName = '';
|
||||
$sourceSiteId = $stagingSite['source_site_id'] ?? null;
|
||||
if (is_string($sourceSiteId) && $sourceSiteId !== '') {
|
||||
$sourceSite = collect($this->sites)->firstWhere('id', $sourceSiteId);
|
||||
$sourceDomainName = strtolower(trim((string) ($sourceSite['domain'] ?? '')));
|
||||
}
|
||||
|
||||
if ($sourceDomainName === '') {
|
||||
$parts = explode('.', $stagingDomainName, 2);
|
||||
if (count($parts) === 2) {
|
||||
$sourceDomainName = $parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($sourceDomainName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceDomain = Domain::query()
|
||||
->where('user_id', Auth::id())
|
||||
->where('domain', $sourceDomainName)
|
||||
->first();
|
||||
|
||||
if (! $sourceDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
|
||||
if ($label === null || $label === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
DnsRecord::query()
|
||||
->where('domain_id', $sourceDomain->id)
|
||||
->where('name', $label)
|
||||
->whereIn('type', ['A', 'AAAA'])
|
||||
->delete();
|
||||
|
||||
try {
|
||||
$this->syncDnsZone($sourceDomain);
|
||||
} catch (Exception) {
|
||||
// Keep deletion successful even if DNS sync is temporarily unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
protected function syncDnsZone(Domain $domain, ?array $settings = null): void
|
||||
{
|
||||
$settings ??= DnsSetting::getAll();
|
||||
$records = DnsRecord::query()->where('domain_id', $domain->id)->get()->toArray();
|
||||
$hostname = gethostname() ?: 'localhost';
|
||||
$serverIp = trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? ''));
|
||||
|
||||
$this->getAgent()->dnsSyncZone($domain->domain, $records, [
|
||||
'ns1' => $settings['ns1'] ?? "ns1.{$hostname}",
|
||||
'ns2' => $settings['ns2'] ?? "ns2.{$hostname}",
|
||||
'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}",
|
||||
'default_ip' => $settings['default_ip'] ?? ($serverIp !== '' ? $serverIp : '127.0.0.1'),
|
||||
'default_ipv6' => $settings['default_ipv6'] ?? null,
|
||||
'default_ttl' => $settings['default_ttl'] ?? 3600,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function extractSubdomainLabel(string $fullDomain, string $baseDomain): ?string
|
||||
{
|
||||
$fullDomain = strtolower(trim($fullDomain, " \t\n\r\0\x0B."));
|
||||
$baseDomain = strtolower(trim($baseDomain, " \t\n\r\0\x0B."));
|
||||
|
||||
if ($fullDomain === '' || $baseDomain === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($fullDomain === $baseDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$suffix = '.'.$baseDomain;
|
||||
if (! str_ends_with($fullDomain, $suffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = substr($fullDomain, 0, -strlen($suffix));
|
||||
|
||||
return trim((string) $label, '.');
|
||||
}
|
||||
|
||||
protected function getOwnedDomainOptions(array $exclude = []): array
|
||||
{
|
||||
$excludeSet = [];
|
||||
foreach ($exclude as $value) {
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
if ($normalized !== '') {
|
||||
$excludeSet[$normalized] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$options = [];
|
||||
foreach ($this->domains as $domain) {
|
||||
$name = strtolower(trim((string) ($domain['domain'] ?? '')));
|
||||
if ($name === '' || isset($excludeSet[$name])) {
|
||||
continue;
|
||||
}
|
||||
$options[$name] = $name;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
protected function isOwnedDomain(string $domain): bool
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
if ($domain === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->domains as $ownedDomain) {
|
||||
$candidate = strtolower(trim((string) ($ownedDomain['domain'] ?? '')));
|
||||
if ($candidate === $domain) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
79
app/Filament/Jabali/Widgets/ActivityLogTable.php
Normal file
79
app/Filament/Jabali/Widgets/ActivityLogTable.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Jabali\Widgets;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class ActivityLogTable extends Component implements HasTable, HasSchemas, HasActions
|
||||
{
|
||||
use InteractsWithTable;
|
||||
use InteractsWithSchemas;
|
||||
use InteractsWithActions;
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
AuditLog::query()
|
||||
->where('user_id', Auth::id())
|
||||
->latest()
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('created_at')
|
||||
->label(__('Time'))
|
||||
->dateTime('M d, H:i')
|
||||
->color('gray'),
|
||||
TextColumn::make('category')
|
||||
->label(__('Category'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'domain' => 'info',
|
||||
'email' => 'primary',
|
||||
'database' => 'warning',
|
||||
'auth' => 'gray',
|
||||
'firewall' => 'danger',
|
||||
'service' => 'success',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('action')
|
||||
->label(__('Action'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'create', 'created' => 'success',
|
||||
'update', 'updated' => 'warning',
|
||||
'delete', 'deleted' => 'danger',
|
||||
'login' => 'info',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('description')
|
||||
->label(__('Description'))
|
||||
->limit(60)
|
||||
->wrap(),
|
||||
TextColumn::make('ip_address')
|
||||
->label(__('IP'))
|
||||
->color('gray'),
|
||||
])
|
||||
->defaultPaginationPageOption(25)
|
||||
->striped()
|
||||
->emptyStateHeading(__('No activity recorded yet'))
|
||||
->emptyStateDescription(__('Recent actions performed in your account will appear here.'))
|
||||
->emptyStateIcon('heroicon-o-clipboard-document-list');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return $this->getTable()->render();
|
||||
}
|
||||
}
|
||||
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,3 +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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -504,13 +504,19 @@ class AgentClient
|
||||
return $this->send('wp.import', $params);
|
||||
}
|
||||
|
||||
public function wpCreateStaging(string $username, string $siteId, string $subdomain): array
|
||||
public function wpCreateStaging(string $username, string $siteId, string $subdomain = 'staging', ?string $targetDomain = null): array
|
||||
{
|
||||
return $this->send('wp.create_staging', [
|
||||
$params = [
|
||||
'username' => $username,
|
||||
'site_id' => $siteId,
|
||||
'subdomain' => $subdomain,
|
||||
]);
|
||||
];
|
||||
|
||||
if ($targetDomain !== null && $targetDomain !== '') {
|
||||
$params['target_domain'] = $targetDomain;
|
||||
}
|
||||
|
||||
return $this->send('wp.create_staging', $params);
|
||||
}
|
||||
|
||||
public function wpPushStaging(string $username, string $stagingSiteId): array
|
||||
|
||||
776
bin/jabali-agent
776
bin/jabali-agent
@@ -7914,6 +7914,8 @@ function wpDelete(array $params): array
|
||||
}
|
||||
|
||||
$site = $wpSites[$siteId];
|
||||
$isStagingSite = (bool) ($site['is_staging'] ?? false);
|
||||
$siteDomain = strtolower(trim((string) ($site['domain'] ?? '')));
|
||||
|
||||
// Delete database if requested
|
||||
$dbDeleted = false;
|
||||
@@ -7943,8 +7945,37 @@ function wpDelete(array $params): array
|
||||
}
|
||||
}
|
||||
|
||||
// Delete files if requested
|
||||
if ($deleteFiles && !empty($site['install_path'])) {
|
||||
if ($isStagingSite && $siteDomain !== '') {
|
||||
$vhostFile = "/etc/nginx/sites-available/{$siteDomain}.conf";
|
||||
|
||||
if (file_exists("/etc/nginx/sites-enabled/{$siteDomain}.conf")) {
|
||||
@unlink("/etc/nginx/sites-enabled/{$siteDomain}.conf");
|
||||
}
|
||||
if (file_exists($vhostFile)) {
|
||||
@unlink($vhostFile);
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
$stagingDomainRoot = "{$userHome}/domains/{$siteDomain}";
|
||||
if (is_dir($stagingDomainRoot)) {
|
||||
exec("rm -rf " . escapeshellarg($stagingDomainRoot));
|
||||
}
|
||||
}
|
||||
|
||||
$domainListFile = "{$userHome}/.domains";
|
||||
if (file_exists($domainListFile)) {
|
||||
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
|
||||
unset($domains[$siteDomain]);
|
||||
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
|
||||
@chown($domainListFile, $userInfo['uid']);
|
||||
@chgrp($domainListFile, $userInfo['gid']);
|
||||
}
|
||||
|
||||
exec("nginx -t 2>&1", $nginxTestOutput, $nginxTestCode);
|
||||
if ($nginxTestCode === 0) {
|
||||
exec("systemctl reload nginx 2>&1");
|
||||
}
|
||||
} elseif ($deleteFiles && !empty($site['install_path'])) {
|
||||
$installPath = $site['install_path'];
|
||||
// Safety check - make sure it's within user's domain folder
|
||||
if (strpos($installPath, "{$userHome}/domains/") === 0) {
|
||||
@@ -7991,8 +8022,50 @@ function wpAutoLogin(array $params): array
|
||||
}
|
||||
|
||||
$site = $wpSites[$siteId];
|
||||
$installPath = $site['install_path'];
|
||||
$adminUser = $site['admin_user'];
|
||||
$installPath = $site['install_path'] ?? '';
|
||||
if ($installPath === '' || !is_dir($installPath)) {
|
||||
return ['success' => false, 'error' => 'WordPress installation path not found'];
|
||||
}
|
||||
|
||||
$adminUser = trim((string) ($site['admin_user'] ?? ''));
|
||||
if ($adminUser === '') {
|
||||
exec(
|
||||
"cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp user list --role=administrator --field=user_login --format=csv 2>&1",
|
||||
$adminOutput,
|
||||
$adminCode
|
||||
);
|
||||
|
||||
if ($adminCode === 0) {
|
||||
$adminLines = array_values(array_filter(array_map('trim', $adminOutput), fn ($line) => $line !== '' && $line !== 'user_login'));
|
||||
if ($adminLines !== []) {
|
||||
$adminUser = (string) $adminLines[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($adminUser === '') {
|
||||
return ['success' => false, 'error' => 'Could not determine WordPress admin user'];
|
||||
}
|
||||
|
||||
// Persist a recovered admin username so future auto-login calls are fast and stable.
|
||||
if (($site['admin_user'] ?? '') !== $adminUser) {
|
||||
$wpSites[$siteId]['admin_user'] = $adminUser;
|
||||
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
|
||||
@chown($wpListFile, $userInfo['uid']);
|
||||
@chgrp($wpListFile, $userInfo['gid']);
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) ($site['url'] ?? ''));
|
||||
if ($baseUrl === '') {
|
||||
$domain = trim((string) ($site['domain'] ?? ''));
|
||||
if ($domain !== '') {
|
||||
$baseUrl = 'https://' . $domain;
|
||||
}
|
||||
}
|
||||
|
||||
if ($baseUrl === '') {
|
||||
return ['success' => false, 'error' => 'Could not determine site URL'];
|
||||
}
|
||||
|
||||
// Skip WP-CLI login package (slow) - use direct fallback method
|
||||
// Generate secure auto-login token
|
||||
@@ -8002,7 +8075,7 @@ function wpAutoLogin(array $params): array
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiry = time() + 300; // 5 minute expiry
|
||||
|
||||
$adminUrl = $site['url'] . '/wp-admin/';
|
||||
$adminUrl = rtrim($baseUrl, '/') . '/wp-admin/';
|
||||
$autoLoginContent = '<?php
|
||||
// Auto-login script - expires after one use or 5 minutes
|
||||
$token = "' . $token . '";
|
||||
@@ -8036,7 +8109,7 @@ die("User not found.");
|
||||
exec("sudo -u " . escapeshellarg($username) . " chmod 644 " . escapeshellarg($autoLoginFile));
|
||||
@unlink($tempFile);
|
||||
|
||||
$loginUrl = $site['url'] . "/jabali-auto-login-{$token}.php?token={$token}";
|
||||
$loginUrl = rtrim($baseUrl, '/') . "/jabali-auto-login-{$token}.php?token={$token}";
|
||||
}
|
||||
|
||||
return ['success' => true, 'login_url' => $loginUrl];
|
||||
@@ -8265,7 +8338,8 @@ function wpCreateStaging(array $params): array
|
||||
{
|
||||
$username = $params['username'] ?? '';
|
||||
$siteId = $params['site_id'] ?? '';
|
||||
$subdomain = $params['subdomain'] ?? 'staging';
|
||||
$subdomain = strtolower(trim((string) ($params['subdomain'] ?? 'staging')));
|
||||
$targetDomain = strtolower(trim((string) ($params['target_domain'] ?? '')));
|
||||
|
||||
if (!validateUsername($username)) {
|
||||
return ['success' => false, 'error' => 'Invalid username'];
|
||||
@@ -8290,15 +8364,412 @@ function wpCreateStaging(array $params): array
|
||||
|
||||
$site = $wpSites[$siteId];
|
||||
$sourcePath = $site['install_path'];
|
||||
$sourceDomain = $site['domain'];
|
||||
$sourceDomain = strtolower(trim((string) ($site['domain'] ?? '')));
|
||||
if ($sourceDomain === '' || !validateDomain($sourceDomain)) {
|
||||
return ['success' => false, 'error' => 'Source domain is invalid'];
|
||||
}
|
||||
|
||||
// Create staging domain
|
||||
$stagingDomain = "{$subdomain}.{$sourceDomain}";
|
||||
// Resolve staging domain from either explicit target domain or subdomain label.
|
||||
if ($targetDomain !== '') {
|
||||
if (!validateDomain($targetDomain)) {
|
||||
return ['success' => false, 'error' => 'Invalid target domain'];
|
||||
}
|
||||
if (!validateUserDomain($username, $targetDomain)) {
|
||||
return ['success' => false, 'error' => 'Target domain does not belong to user'];
|
||||
}
|
||||
if ($targetDomain === $sourceDomain) {
|
||||
return ['success' => false, 'error' => 'Target domain must be different from source domain'];
|
||||
}
|
||||
$stagingDomain = $targetDomain;
|
||||
} else {
|
||||
if ($subdomain === '') {
|
||||
$subdomain = 'staging';
|
||||
}
|
||||
if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i', $subdomain) !== 1) {
|
||||
return ['success' => false, 'error' => 'Invalid subdomain label'];
|
||||
}
|
||||
$stagingDomain = "{$subdomain}.{$sourceDomain}";
|
||||
}
|
||||
|
||||
$stagingDomainRoot = "{$userHome}/domains/{$stagingDomain}";
|
||||
$stagingPath = "{$userHome}/domains/{$stagingDomain}/public_html";
|
||||
$stagingUrl = "https://{$stagingDomain}";
|
||||
|
||||
// Check if staging already exists
|
||||
if (is_dir($stagingPath)) {
|
||||
return ['success' => false, 'error' => 'Staging site already exists at ' . $stagingDomain];
|
||||
$syncStagingUrls = function (string $targetPath, string $targetUrl, string $originalUrl, string $originalDomain, string $targetDomain) use ($username): array {
|
||||
$replacePairs = [];
|
||||
$normalizedOriginalUrl = trim($originalUrl);
|
||||
if ($normalizedOriginalUrl !== '' && $normalizedOriginalUrl !== $targetUrl) {
|
||||
$replacePairs[$normalizedOriginalUrl] = $targetUrl;
|
||||
}
|
||||
|
||||
$replacePairs["https://{$originalDomain}"] = "https://{$targetDomain}";
|
||||
$replacePairs["http://{$originalDomain}"] = "http://{$targetDomain}";
|
||||
// Avoid raw domain replacement because repeated syncs can duplicate subdomains
|
||||
// (e.g. staging.example.com -> staging.staging.example.com).
|
||||
|
||||
foreach ($replacePairs as $search => $replace) {
|
||||
if ($search === '' || $search === $replace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replaceCmd = "cd " . escapeshellarg($targetPath)
|
||||
. " && sudo -u " . escapeshellarg($username)
|
||||
. " wp search-replace " . escapeshellarg($search)
|
||||
. " " . escapeshellarg($replace)
|
||||
. " --all-tables --skip-columns=guid --quiet 2>&1";
|
||||
exec($replaceCmd, $replaceOutput, $replaceCode);
|
||||
if ($replaceCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to update staging URLs'];
|
||||
}
|
||||
}
|
||||
|
||||
// Repair any legacy duplicated-subdomain values created by previous sync logic.
|
||||
$suffix = '.' . $originalDomain;
|
||||
if (str_ends_with($targetDomain, $suffix)) {
|
||||
$targetLabel = substr($targetDomain, 0, -strlen($suffix));
|
||||
$targetLabel = trim((string) $targetLabel, '.');
|
||||
if ($targetLabel !== '') {
|
||||
$duplicateDomain = $targetLabel . '.' . $targetDomain;
|
||||
if ($duplicateDomain !== $targetDomain) {
|
||||
$cleanupPairs = [
|
||||
"https://{$duplicateDomain}" => "https://{$targetDomain}",
|
||||
"http://{$duplicateDomain}" => "http://{$targetDomain}",
|
||||
$duplicateDomain => $targetDomain,
|
||||
];
|
||||
|
||||
foreach ($cleanupPairs as $search => $replace) {
|
||||
$cleanupCmd = "cd " . escapeshellarg($targetPath)
|
||||
. " && sudo -u " . escapeshellarg($username)
|
||||
. " wp search-replace " . escapeshellarg($search)
|
||||
. " " . escapeshellarg($replace)
|
||||
. " --all-tables --skip-columns=guid --quiet 2>&1";
|
||||
exec($cleanupCmd, $cleanupOutput, $cleanupCode);
|
||||
if ($cleanupCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to clean duplicated staging URLs'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$updateHomeCmd = "cd " . escapeshellarg($targetPath)
|
||||
. " && sudo -u " . escapeshellarg($username)
|
||||
. " wp option update home " . escapeshellarg($targetUrl) . " --quiet 2>&1";
|
||||
exec($updateHomeCmd, $updateHomeOutput, $updateHomeCode);
|
||||
if ($updateHomeCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to set staging home URL'];
|
||||
}
|
||||
|
||||
$updateSiteUrlCmd = "cd " . escapeshellarg($targetPath)
|
||||
. " && sudo -u " . escapeshellarg($username)
|
||||
. " wp option update siteurl " . escapeshellarg($targetUrl) . " --quiet 2>&1";
|
||||
exec($updateSiteUrlCmd, $updateSiteUrlOutput, $updateSiteUrlCode);
|
||||
if ($updateSiteUrlCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to set staging site URL'];
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
};
|
||||
|
||||
$refreshStagingDatabase = function (string $sourceDatabase, string $targetDatabase) use ($siteId): array {
|
||||
if ($sourceDatabase === '' || $targetDatabase === '') {
|
||||
return ['success' => false, 'error' => 'Missing source or target database for staging refresh'];
|
||||
}
|
||||
|
||||
$dumpFile = "/tmp/wp_staging_refresh_{$siteId}_" . bin2hex(random_bytes(4)) . ".sql";
|
||||
|
||||
exec(
|
||||
"mysqldump --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($sourceDatabase) . " > " . escapeshellarg($dumpFile) . " 2>&1",
|
||||
$dumpOutput,
|
||||
$dumpCode
|
||||
);
|
||||
if ($dumpCode !== 0) {
|
||||
@unlink($dumpFile);
|
||||
return ['success' => false, 'error' => 'Failed to export source database for staging refresh'];
|
||||
}
|
||||
|
||||
$dropSql = "DROP DATABASE IF EXISTS `" . addslashes($targetDatabase) . "`";
|
||||
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($dropSql) . " 2>&1", $dropOutput, $dropCode);
|
||||
if ($dropCode !== 0) {
|
||||
@unlink($dumpFile);
|
||||
return ['success' => false, 'error' => 'Failed to reset staging database'];
|
||||
}
|
||||
|
||||
$createSql = "CREATE DATABASE `" . addslashes($targetDatabase) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
|
||||
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createSql) . " 2>&1", $createOutput, $createCode);
|
||||
if ($createCode !== 0) {
|
||||
@unlink($dumpFile);
|
||||
return ['success' => false, 'error' => 'Failed to recreate staging database'];
|
||||
}
|
||||
|
||||
exec(
|
||||
"mysql --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($targetDatabase) . " < " . escapeshellarg($dumpFile) . " 2>&1",
|
||||
$importOutput,
|
||||
$importCode
|
||||
);
|
||||
@unlink($dumpFile);
|
||||
if ($importCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to import refreshed staging database'];
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
};
|
||||
|
||||
$syncStagingCacheIsolation = function (string $targetPath, string $targetDomain) use ($username, $userInfo): array {
|
||||
$wpConfigPath = rtrim($targetPath, '/') . '/wp-config.php';
|
||||
$wpConfig = @file_get_contents($wpConfigPath);
|
||||
if (!is_string($wpConfig) || $wpConfig === '') {
|
||||
return ['success' => false, 'error' => 'Failed to read staging wp-config.php for cache isolation'];
|
||||
}
|
||||
|
||||
$domainToken = strtolower($targetDomain);
|
||||
$domainToken = preg_replace('/[^a-z0-9_]+/', '_', $domainToken);
|
||||
$domainToken = trim((string) $domainToken, '_');
|
||||
if ($domainToken === '') {
|
||||
$domainToken = 'staging_site';
|
||||
}
|
||||
|
||||
$cachePrefix = 'jc_' . substr($domainToken, 0, 44) . '_';
|
||||
$cacheSalt = 'jabali_stage_' . substr(hash('sha256', $targetDomain . '|' . $targetPath), 0, 40);
|
||||
|
||||
$setConfigConstant = function (string $constantName, string $constantValue) use (&$wpConfig): bool {
|
||||
$escapedValue = str_replace(['\\', '\''], ['\\\\', '\\\''], $constantValue);
|
||||
$replacement = "define('{$constantName}', '{$escapedValue}');";
|
||||
$pattern = "/define\\s*\\(\\s*['\\\"]" . preg_quote($constantName, '/') . "['\\\"]\\s*,\\s*.+?\\)\\s*;/is";
|
||||
|
||||
if (preg_match($pattern, $wpConfig) === 1) {
|
||||
$wpConfig = preg_replace($pattern, $replacement, $wpConfig, 1, $count);
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
if (preg_match('/^<\\?php\\s*/i', $wpConfig) === 1) {
|
||||
$wpConfig = preg_replace('/^<\\?php\\s*/i', "<?php\n{$replacement}\n", $wpConfig, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
$saltSet = $setConfigConstant('WP_CACHE_KEY_SALT', $cacheSalt);
|
||||
$prefixSet = $setConfigConstant('JABALI_CACHE_PREFIX', $cachePrefix);
|
||||
if (!$saltSet || !$prefixSet) {
|
||||
return ['success' => false, 'error' => 'Failed to set staging cache constants in wp-config.php'];
|
||||
}
|
||||
|
||||
if (@file_put_contents($wpConfigPath, $wpConfig) === false) {
|
||||
return ['success' => false, 'error' => 'Failed to write staging cache constants to wp-config.php'];
|
||||
}
|
||||
@chown($wpConfigPath, $userInfo['uid']);
|
||||
@chgrp($wpConfigPath, $userInfo['gid']);
|
||||
|
||||
$flushCmd = "cd " . escapeshellarg($targetPath)
|
||||
. " && sudo -u " . escapeshellarg($username)
|
||||
. " wp cache flush 2>&1";
|
||||
exec($flushCmd, $flushOutput, $flushCode);
|
||||
if ($flushCode !== 0) {
|
||||
// Cache flush can fail when no object-cache drop-in is active.
|
||||
// Keep migration flow successful as constants are already isolated.
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'cache_prefix' => $cachePrefix,
|
||||
];
|
||||
};
|
||||
|
||||
// Check if staging is already tracked (idempotent for repeated create requests).
|
||||
// Protect existing non-staging sites from accidental overwrite.
|
||||
$existingTrackedId = null;
|
||||
$existingTrackedSite = null;
|
||||
foreach ($wpSites as $candidateId => $existingSite) {
|
||||
$candidateDomain = strtolower(trim((string) ($existingSite['domain'] ?? '')));
|
||||
if ($candidateDomain !== $stagingDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidateIsStaging = (bool) ($existingSite['is_staging'] ?? false);
|
||||
$candidateSourceSiteId = (string) ($existingSite['source_site_id'] ?? '');
|
||||
|
||||
if ($candidateIsStaging && $candidateSourceSiteId === $siteId) {
|
||||
$existingTrackedId = (string) $candidateId;
|
||||
$existingTrackedSite = is_array($existingSite) ? $existingSite : null;
|
||||
break;
|
||||
}
|
||||
|
||||
return ['success' => false, 'error' => 'Target domain already used by another WordPress site'];
|
||||
}
|
||||
|
||||
if ($existingTrackedId !== null && is_array($existingTrackedSite)) {
|
||||
$existingInstallPath = trim((string) ($existingTrackedSite['install_path'] ?? ''));
|
||||
$existingUrl = trim((string) ($existingTrackedSite['url'] ?? ''));
|
||||
|
||||
if ($existingInstallPath !== '' && is_dir($existingInstallPath)) {
|
||||
$trackedDbName = trim((string) ($existingTrackedSite['db_name'] ?? ''));
|
||||
$trackedDbUser = trim((string) ($existingTrackedSite['db_user'] ?? ''));
|
||||
$sourceDbNameForRefresh = '';
|
||||
$sourceDbUserForRefresh = '';
|
||||
|
||||
$sourceWpConfigPath = rtrim((string) $sourcePath, '/') . '/wp-config.php';
|
||||
$sourceWpConfig = @file_get_contents($sourceWpConfigPath);
|
||||
if (is_string($sourceWpConfig) && $sourceWpConfig !== '') {
|
||||
preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $sourceWpConfig, $sourceDbNameMatch);
|
||||
preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $sourceWpConfig, $sourceDbUserMatch);
|
||||
$sourceDbNameForRefresh = $sourceDbNameMatch[1] ?? '';
|
||||
$sourceDbUserForRefresh = $sourceDbUserMatch[1] ?? '';
|
||||
}
|
||||
if ($sourceDbNameForRefresh === '') {
|
||||
$sourceDbNameForRefresh = trim((string) ($site['db_name'] ?? ''));
|
||||
}
|
||||
if ($sourceDbUserForRefresh === '') {
|
||||
$sourceDbUserForRefresh = trim((string) ($site['db_user'] ?? ''));
|
||||
}
|
||||
if ($sourceDbUserForRefresh === '') {
|
||||
$sourceDbUserForRefresh = $sourceDbNameForRefresh;
|
||||
}
|
||||
|
||||
if ($sourceDbNameForRefresh === '') {
|
||||
return ['success' => false, 'error' => 'Could not read source database name for staging refresh'];
|
||||
}
|
||||
|
||||
$trackedDbNameLower = strtolower($trackedDbName);
|
||||
$trackedDbUserLower = strtolower($trackedDbUser);
|
||||
$sourceDbNameLower = strtolower($sourceDbNameForRefresh);
|
||||
$sourceDbUserLower = strtolower($sourceDbUserForRefresh);
|
||||
|
||||
// Old staging entries may still reuse source DB credentials.
|
||||
// Remove those tracked entries and recreate from scratch with isolated DB creds.
|
||||
if (
|
||||
$trackedDbName === ''
|
||||
|| $trackedDbUser === ''
|
||||
|| $trackedDbNameLower === $sourceDbNameLower
|
||||
|| $trackedDbUserLower === $sourceDbUserLower
|
||||
) {
|
||||
unset($wpSites[$existingTrackedId]);
|
||||
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
|
||||
|
||||
$staleVhost = "/etc/nginx/sites-available/{$stagingDomain}.conf";
|
||||
if (file_exists("/etc/nginx/sites-enabled/{$stagingDomain}.conf")) {
|
||||
@unlink("/etc/nginx/sites-enabled/{$stagingDomain}.conf");
|
||||
}
|
||||
if (file_exists($staleVhost)) {
|
||||
@unlink($staleVhost);
|
||||
}
|
||||
if (is_dir($stagingDomainRoot)) {
|
||||
exec("rm -rf " . escapeshellarg($stagingDomainRoot));
|
||||
}
|
||||
} else {
|
||||
if ($trackedDbName !== '' && $trackedDbUser !== '') {
|
||||
$existingWpConfigPath = rtrim($existingInstallPath, '/') . '/wp-config.php';
|
||||
$existingWpConfig = @file_get_contents($existingWpConfigPath);
|
||||
$configDbName = '';
|
||||
$configDbUser = '';
|
||||
|
||||
if (is_string($existingWpConfig) && $existingWpConfig !== '') {
|
||||
preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $existingWpConfig, $existingDbNameMatch);
|
||||
preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $existingWpConfig, $existingDbUserMatch);
|
||||
$configDbName = $existingDbNameMatch[1] ?? '';
|
||||
$configDbUser = $existingDbUserMatch[1] ?? '';
|
||||
}
|
||||
|
||||
// Self-heal old staging records where wp-config drifted from tracked DB credentials.
|
||||
if ($configDbName !== $trackedDbName || $configDbUser !== $trackedDbUser) {
|
||||
$repairedDbPass = bin2hex(random_bytes(12));
|
||||
$createUserSql = "CREATE USER IF NOT EXISTS '" . addslashes($trackedDbUser) . "'@'localhost' IDENTIFIED BY '" . addslashes($repairedDbPass) . "'";
|
||||
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createUserSql) . " 2>&1", $repairUserOutput, $repairUserCode);
|
||||
if ($repairUserCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to repair staging database user'];
|
||||
}
|
||||
|
||||
$alterUserSql = "ALTER USER '" . addslashes($trackedDbUser) . "'@'localhost' IDENTIFIED BY '" . addslashes($repairedDbPass) . "'";
|
||||
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($alterUserSql) . " 2>&1", $repairAlterOutput, $repairAlterCode);
|
||||
if ($repairAlterCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to reset staging database user password'];
|
||||
}
|
||||
|
||||
$grantSql = "GRANT ALL PRIVILEGES ON `" . addslashes($trackedDbName) . "`.* TO '" . addslashes($trackedDbUser) . "'@'localhost'";
|
||||
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($grantSql) . " 2>&1", $repairGrantOutput, $repairGrantCode);
|
||||
if ($repairGrantCode !== 0) {
|
||||
return ['success' => false, 'error' => 'Failed to repair staging database grants'];
|
||||
}
|
||||
|
||||
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg("FLUSH PRIVILEGES") . " 2>&1");
|
||||
|
||||
$setExistingDbNameCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username)
|
||||
. " wp config set DB_NAME " . escapeshellarg($trackedDbName) . " --type=constant --quiet 2>&1";
|
||||
exec($setExistingDbNameCmd);
|
||||
|
||||
$setExistingDbUserCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username)
|
||||
. " wp config set DB_USER " . escapeshellarg($trackedDbUser) . " --type=constant --quiet 2>&1";
|
||||
exec($setExistingDbUserCmd);
|
||||
|
||||
$setExistingDbPassCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username)
|
||||
. " wp config set DB_PASSWORD " . escapeshellarg($repairedDbPass) . " --type=constant --quiet 2>&1";
|
||||
exec($setExistingDbPassCmd);
|
||||
|
||||
$existingWpConfig = (string) @file_get_contents($existingWpConfigPath);
|
||||
$existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_NAME', '{$trackedDbName}');", $existingWpConfig, 1);
|
||||
$existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_USER', '{$trackedDbUser}');", $existingWpConfig, 1);
|
||||
$existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_PASSWORD', '{$repairedDbPass}');", $existingWpConfig, 1);
|
||||
file_put_contents($existingWpConfigPath, $existingWpConfig);
|
||||
@chown($existingWpConfigPath, $userInfo['uid']);
|
||||
@chgrp($existingWpConfigPath, $userInfo['gid']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($trackedDbName !== '') {
|
||||
$refreshResult = $refreshStagingDatabase($sourceDbNameForRefresh, $trackedDbName);
|
||||
if (!($refreshResult['success'] ?? false)) {
|
||||
return ['success' => false, 'error' => $refreshResult['error'] ?? 'Failed to refresh staging database'];
|
||||
}
|
||||
}
|
||||
|
||||
$urlSyncResult = $syncStagingUrls(
|
||||
$existingInstallPath,
|
||||
$stagingUrl,
|
||||
(string) ($site['url'] ?? ''),
|
||||
$sourceDomain,
|
||||
$stagingDomain
|
||||
);
|
||||
if (!($urlSyncResult['success'] ?? false)) {
|
||||
return ['success' => false, 'error' => $urlSyncResult['error'] ?? 'Failed to sync staging URLs'];
|
||||
}
|
||||
|
||||
$cacheSyncResult = $syncStagingCacheIsolation($existingInstallPath, $stagingDomain);
|
||||
if (!($cacheSyncResult['success'] ?? false)) {
|
||||
return ['success' => false, 'error' => $cacheSyncResult['error'] ?? 'Failed to isolate staging cache settings'];
|
||||
}
|
||||
$wpSites[$existingTrackedId]['cache_prefix'] = (string) ($cacheSyncResult['cache_prefix'] ?? '');
|
||||
if (($site['cache_enabled'] ?? false) || file_exists($existingInstallPath . '/wp-content/object-cache.php')) {
|
||||
$wpSites[$existingTrackedId]['cache_enabled'] = true;
|
||||
}
|
||||
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
|
||||
|
||||
$existingUrl = $stagingUrl;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'staging_url' => $existingUrl,
|
||||
'staging_domain' => $stagingDomain,
|
||||
'staging_site_id' => $existingTrackedId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Tracked entry is stale (path missing). Remove it and continue with fresh creation.
|
||||
unset($wpSites[$existingTrackedId]);
|
||||
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
// Check for stale filesystem leftovers and self-heal before creating.
|
||||
if (is_dir($stagingPath) || is_dir($stagingDomainRoot)) {
|
||||
$staleVhost = "/etc/nginx/sites-available/{$stagingDomain}.conf";
|
||||
if (file_exists("/etc/nginx/sites-enabled/{$stagingDomain}.conf")) {
|
||||
@unlink("/etc/nginx/sites-enabled/{$stagingDomain}.conf");
|
||||
}
|
||||
if (file_exists($staleVhost)) {
|
||||
@unlink($staleVhost);
|
||||
}
|
||||
exec("rm -rf " . escapeshellarg($stagingDomainRoot));
|
||||
}
|
||||
|
||||
// Create staging directories
|
||||
@@ -8346,19 +8817,53 @@ function wpCreateStaging(array $params): array
|
||||
$sourceDbUser = $dbUserMatch[1] ?? '';
|
||||
$sourceDbPass = $dbPassMatch[1] ?? '';
|
||||
|
||||
// Fallback to tracked metadata if wp-config uses non-string expressions.
|
||||
if ($sourceDbName === '') {
|
||||
$sourceDbName = (string) ($site['db_name'] ?? '');
|
||||
}
|
||||
if ($sourceDbUser === '') {
|
||||
$sourceDbUser = (string) ($site['db_user'] ?? '');
|
||||
}
|
||||
if ($sourceDbUser === '') {
|
||||
$sourceDbUser = $sourceDbName;
|
||||
}
|
||||
|
||||
if (empty($sourceDbName)) {
|
||||
return ['success' => false, 'error' => 'Could not read source database name'];
|
||||
}
|
||||
if (empty($sourceDbUser)) {
|
||||
return ['success' => false, 'error' => 'Could not read source database user'];
|
||||
}
|
||||
|
||||
// Create staging database and user
|
||||
$suffix = preg_replace('/[^a-zA-Z0-9_]+/', '_', $subdomain);
|
||||
// Create staging database and user.
|
||||
// Keep suffix preserved so names cannot truncate back to source credentials.
|
||||
$suffixSeed = $targetDomain !== '' ? $stagingDomain : $subdomain;
|
||||
$suffix = preg_replace('/[^a-zA-Z0-9_]+/', '_', $suffixSeed);
|
||||
$suffix = trim((string) $suffix, '_');
|
||||
if ($suffix === '') {
|
||||
$suffix = 'staging';
|
||||
}
|
||||
|
||||
$stagingDbName = substr($sourceDbName . '_' . $suffix . '_stg', 0, 64);
|
||||
$stagingDbUser = substr($sourceDbUser . '_' . $suffix . '_stg', 0, 32);
|
||||
$buildStagingIdentifier = function (string $source, string $suffixValue, int $maxLength): string {
|
||||
$token = '_' . $suffixValue . '_stg';
|
||||
if (strlen($token) >= $maxLength) {
|
||||
$token = '_' . substr(sha1($suffixValue), 0, max(6, $maxLength - 2));
|
||||
}
|
||||
|
||||
$baseMaxLength = max(1, $maxLength - strlen($token));
|
||||
$base = substr($source, 0, $baseMaxLength);
|
||||
|
||||
return $base . $token;
|
||||
};
|
||||
|
||||
$stagingDbName = $buildStagingIdentifier($sourceDbName, $suffix, 64);
|
||||
$stagingDbUser = $buildStagingIdentifier($sourceDbUser, $suffix, 32);
|
||||
if ($stagingDbName === $sourceDbName) {
|
||||
$stagingDbName = substr($sourceDbName, 0, 54) . '_stg_' . substr(sha1($suffix), 0, 5);
|
||||
}
|
||||
if ($stagingDbUser === $sourceDbUser) {
|
||||
$stagingDbUser = substr($sourceDbUser, 0, 22) . '_stg_' . substr(sha1($suffix), 0, 5);
|
||||
}
|
||||
$stagingDbPass = bin2hex(random_bytes(12));
|
||||
|
||||
// Export and import database
|
||||
@@ -8401,32 +8906,89 @@ function wpCreateStaging(array $params): array
|
||||
return ['success' => false, 'error' => 'Failed to import database'];
|
||||
}
|
||||
|
||||
// Update wp-config.php with new database credentials
|
||||
$wpConfig = preg_replace(
|
||||
"/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/",
|
||||
"define('DB_NAME', '{$stagingDbName}')",
|
||||
$wpConfig
|
||||
);
|
||||
$wpConfig = preg_replace(
|
||||
"/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/",
|
||||
"define('DB_USER', '{$stagingDbUser}')",
|
||||
$wpConfig
|
||||
);
|
||||
$wpConfig = preg_replace(
|
||||
"/define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"][^'\"]*['\"]\s*\)/",
|
||||
"define('DB_PASSWORD', '{$stagingDbPass}')",
|
||||
$wpConfig
|
||||
);
|
||||
file_put_contents($wpConfigPath, $wpConfig);
|
||||
// Force-update wp-config.php with staging database credentials.
|
||||
// First try WP-CLI config set (handles many formatting variants), then regex fallback.
|
||||
$setDbNameCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username)
|
||||
. " wp config set DB_NAME " . escapeshellarg($stagingDbName) . " --type=constant --quiet 2>&1";
|
||||
exec($setDbNameCmd, $setDbNameOut, $setDbNameCode);
|
||||
|
||||
// Update URLs in staging database
|
||||
$stagingUrl = "https://{$stagingDomain}";
|
||||
$sourceUrl = $site['url'];
|
||||
$setDbUserCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username)
|
||||
. " wp config set DB_USER " . escapeshellarg($stagingDbUser) . " --type=constant --quiet 2>&1";
|
||||
exec($setDbUserCmd, $setDbUserOut, $setDbUserCode);
|
||||
|
||||
exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceUrl) . " " . escapeshellarg($stagingUrl) . " --all-tables 2>&1");
|
||||
$setDbPassCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username)
|
||||
. " wp config set DB_PASSWORD " . escapeshellarg($stagingDbPass) . " --type=constant --quiet 2>&1";
|
||||
exec($setDbPassCmd, $setDbPassOut, $setDbPassCode);
|
||||
|
||||
// Also replace without protocol
|
||||
exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceDomain) . " " . escapeshellarg($stagingDomain) . " --all-tables 2>&1");
|
||||
$wpConfigAfterSet = @file_get_contents($wpConfigPath);
|
||||
if (!is_string($wpConfigAfterSet) || $wpConfigAfterSet === '') {
|
||||
return ['success' => false, 'error' => 'Failed to read staging wp-config.php after DB update'];
|
||||
}
|
||||
|
||||
$nameUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbName, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
|
||||
$userUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbUser, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
|
||||
$passUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbPass, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
|
||||
|
||||
if (!$nameUpdated || !$userUpdated || !$passUpdated) {
|
||||
$nameCount = 0;
|
||||
$userCount = 0;
|
||||
$passCount = 0;
|
||||
|
||||
$wpConfigAfterSet = preg_replace(
|
||||
"/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*.+?\\)\\s*;/is",
|
||||
"define('DB_NAME', '{$stagingDbName}');",
|
||||
$wpConfigAfterSet,
|
||||
1,
|
||||
$nameCount
|
||||
);
|
||||
$wpConfigAfterSet = preg_replace(
|
||||
"/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*.+?\\)\\s*;/is",
|
||||
"define('DB_USER', '{$stagingDbUser}');",
|
||||
$wpConfigAfterSet,
|
||||
1,
|
||||
$userCount
|
||||
);
|
||||
$wpConfigAfterSet = preg_replace(
|
||||
"/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*.+?\\)\\s*;/is",
|
||||
"define('DB_PASSWORD', '{$stagingDbPass}');",
|
||||
$wpConfigAfterSet,
|
||||
1,
|
||||
$passCount
|
||||
);
|
||||
|
||||
if ($nameCount === 0 || $userCount === 0 || $passCount === 0) {
|
||||
return ['success' => false, 'error' => 'Failed to update staging wp-config.php database constants'];
|
||||
}
|
||||
|
||||
file_put_contents($wpConfigPath, $wpConfigAfterSet);
|
||||
@chown($wpConfigPath, $userInfo['uid']);
|
||||
@chgrp($wpConfigPath, $userInfo['gid']);
|
||||
|
||||
$wpConfigAfterSet = (string) @file_get_contents($wpConfigPath);
|
||||
$nameUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbName, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
|
||||
$userUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbUser, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
|
||||
$passUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbPass, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
|
||||
}
|
||||
|
||||
if (!$nameUpdated || !$userUpdated || !$passUpdated) {
|
||||
return ['success' => false, 'error' => 'Staging wp-config.php database credentials verification failed'];
|
||||
}
|
||||
|
||||
$urlSyncResult = $syncStagingUrls(
|
||||
$stagingPath,
|
||||
$stagingUrl,
|
||||
(string) ($site['url'] ?? ''),
|
||||
$sourceDomain,
|
||||
$stagingDomain
|
||||
);
|
||||
if (!($urlSyncResult['success'] ?? false)) {
|
||||
return ['success' => false, 'error' => $urlSyncResult['error'] ?? 'Failed to sync staging URLs'];
|
||||
}
|
||||
|
||||
$cacheSyncResult = $syncStagingCacheIsolation($stagingPath, $stagingDomain);
|
||||
if (!($cacheSyncResult['success'] ?? false)) {
|
||||
return ['success' => false, 'error' => $cacheSyncResult['error'] ?? 'Failed to isolate staging cache settings'];
|
||||
}
|
||||
|
||||
// Create Nginx config for staging (includes HTTPS with snakeoil cert)
|
||||
createFpmPool($username, false);
|
||||
@@ -8441,6 +9003,21 @@ function wpCreateStaging(array $params): array
|
||||
// Reload Nginx
|
||||
exec("systemctl reload nginx 2>&1");
|
||||
|
||||
// Store staging domain in user's domain registry so it appears in Domain Manager.
|
||||
$domainListFile = "{$userHome}/.domains";
|
||||
$domains = [];
|
||||
if (file_exists($domainListFile)) {
|
||||
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
|
||||
}
|
||||
$domains[$stagingDomain] = [
|
||||
'created' => date('Y-m-d H:i:s'),
|
||||
'document_root' => $stagingPath,
|
||||
'ssl' => false,
|
||||
];
|
||||
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
|
||||
@chown($domainListFile, $userInfo['uid']);
|
||||
@chgrp($domainListFile, $userInfo['gid']);
|
||||
|
||||
// Add staging site to WordPress sites list
|
||||
$stagingSiteId = 'staging_' . $siteId . '_' . time();
|
||||
$wpSites[$stagingSiteId] = [
|
||||
@@ -8448,10 +9025,14 @@ function wpCreateStaging(array $params): array
|
||||
'domain' => $stagingDomain,
|
||||
'path' => '',
|
||||
'url' => $stagingUrl,
|
||||
'admin_user' => (string) ($site['admin_user'] ?? 'admin'),
|
||||
'admin_email' => (string) ($site['admin_email'] ?? ''),
|
||||
'install_path' => $stagingPath,
|
||||
'db_name' => $stagingDbName,
|
||||
'db_user' => $stagingDbUser,
|
||||
'version' => $site['version'] ?? 'Unknown',
|
||||
'cache_enabled' => (bool) (($site['cache_enabled'] ?? false) || file_exists($stagingPath . '/wp-content/object-cache.php')),
|
||||
'cache_prefix' => (string) ($cacheSyncResult['cache_prefix'] ?? ''),
|
||||
'is_staging' => true,
|
||||
'source_site_id' => $siteId,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
@@ -12681,23 +13262,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 +13306,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 +13496,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 +13558,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 +13576,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 +13602,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 +13616,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'];
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"filament/blueprint": "^2.0",
|
||||
"filament/blueprint": "^2.1",
|
||||
"laravel/boost": "*",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
|
||||
8
composer.lock
generated
8
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "194d87cc129a30c6e832109fb820097a",
|
||||
"content-hash": "7083b0b087c4b503b50d3aa23cfbbfac",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -8817,14 +8817,14 @@
|
||||
},
|
||||
{
|
||||
"name": "filament/blueprint",
|
||||
"version": "v2.0.1",
|
||||
"version": "v2.1.0",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://packages.filamentphp.com/composer/10/127/download"
|
||||
"url": "https://packages.filamentphp.com/composer/10/473/download"
|
||||
},
|
||||
"require": {
|
||||
"filament/support": "^5.0",
|
||||
"laravel/boost": "^1.8"
|
||||
"laravel/boost": "^1.8|^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"license": [
|
||||
|
||||
@@ -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-06
|
||||
Last updated: 2026-02-10
|
||||
|
||||
## Top-Level Docs
|
||||
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
|
||||
@@ -11,8 +11,9 @@ Last updated: 2026-02-06
|
||||
- /var/www/jabali/TODO.md - Active checklist items.
|
||||
|
||||
## Docs Folder
|
||||
- /var/www/jabali/docs/installation.md - Debian package install path and Filament notifications patch.
|
||||
- /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-06
|
||||
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.
|
||||
@@ -41,6 +41,7 @@ DNSSEC can be enabled per domain and generates KSK/ZSK keys, DS records, and sig
|
||||
- Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack.
|
||||
- Installer: install.sh, builds assets as www-data and ensures permissions.
|
||||
- Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules.
|
||||
- Deploy helper: scripts/deploy.sh syncs code to a server, runs composer/npm, migrations, and caches, and can push to Gitea/GitHub with automatic VERSION bump.
|
||||
|
||||
## Packaging
|
||||
Debian packaging is supported via scripts:
|
||||
@@ -52,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.
|
||||
|
||||
@@ -110,6 +110,51 @@ What is included:
|
||||
If you update or rebuild assets, keep the guard in place and hard‑refresh the
|
||||
browser (Ctrl+Shift+R) after deployment.
|
||||
|
||||
## Deploy script
|
||||
|
||||
The repository ships with a deploy helper at `scripts/deploy.sh`. It syncs the
|
||||
project to a remote server over SSH, then runs composer/npm, migrations, and
|
||||
cache rebuilds as the web user.
|
||||
|
||||
Defaults (override via flags or env vars):
|
||||
- Host: `192.168.100.50`
|
||||
- User: `root`
|
||||
- Path: `/var/www/jabali`
|
||||
- Web user: `www-data`
|
||||
|
||||
Common usage:
|
||||
```
|
||||
# Basic deploy to the default host
|
||||
scripts/deploy.sh
|
||||
|
||||
# Target a different host/path/user
|
||||
scripts/deploy.sh --host 192.168.100.50 --user root --path /var/www/jabali --www-user www-data
|
||||
|
||||
# Dry-run rsync only
|
||||
scripts/deploy.sh --dry-run
|
||||
|
||||
# Skip npm build and cache steps
|
||||
scripts/deploy.sh --skip-npm --skip-cache
|
||||
```
|
||||
|
||||
Push to Git remotes (optional):
|
||||
```
|
||||
# Push to Gitea and/or GitHub before deploying
|
||||
scripts/deploy.sh --push-gitea --push-github
|
||||
|
||||
# Push to explicit URLs
|
||||
scripts/deploy.sh --push-gitea --gitea-url http://192.168.100.100:3001/shukivaknin/jabali-panel.git \
|
||||
--push-github --github-url git@github.com:shukiv/jabali-panel.git
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--push-gitea` / `--push-github` require a clean worktree.
|
||||
- When pushing, the script bumps `VERSION`, updates the `install.sh` fallback,
|
||||
and commits the version bump before pushing.
|
||||
- Rsync excludes `.env`, `storage/`, `vendor/`, `node_modules/`, `public/build/`,
|
||||
`bootstrap/cache/`, and SQLite DB files. Handle those separately if needed.
|
||||
- `--delete` passes `--delete` to rsync (dangerous).
|
||||
|
||||
## Testing after changes
|
||||
|
||||
After every change, run a test to make sure there are no errors.
|
||||
|
||||
@@ -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-rc54}"
|
||||
JABALI_VERSION="${JABALI_VERSION:-0.9-rc62}"
|
||||
|
||||
# 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"
|
||||
}
|
||||
|
||||
16
lang/ar.json
16
lang/ar.json
@@ -219,6 +219,20 @@
|
||||
"Disk Usage": "استخدام القرص",
|
||||
"Display Name": "الاسم المعروض",
|
||||
"Document Root": "المجلد الجذر",
|
||||
"Documentation": "التوثيق",
|
||||
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "اعثر على إجابات في التوثيق أو تحدث مع روبوت الدعم المدرَّب لدينا. اطّلع على أدلة الإعداد وخطوات استكشاف الأخطاء وأفضل الممارسات.",
|
||||
"Chat with our AI support bot.": "تحدث مع روبوت الدعم بالذكاء الاصطناعي.",
|
||||
"GitHub Issues": "مشكلات GitHub",
|
||||
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "أبلغ عن الأخطاء أو اطلب الميزات. أرفق الخطوات والسجلات ولقطات الشاشة لنتمكن من إعادة الإنتاج بسرعة.",
|
||||
"Open GitHub Issues": "فتح مشكلات GitHub",
|
||||
"Paid Support": "دعم مدفوع",
|
||||
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "احصل على مساعدة احترافية للترحيل وتحسين الأداء والإصلاحات ذات الأولوية. تتضمن الخطط الإعداد والدعم المخصص.",
|
||||
"View Support Plans": "عرض خطط الدعم",
|
||||
"Response Time": "وقت الاستجابة",
|
||||
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "نستجيب عادة خلال 4-8 ساعات. للحوادث الحرجة، استخدم دعم الطوارئ للحصول على استجابة أسرع.",
|
||||
"Emergency Support": "دعم الطوارئ",
|
||||
"Open Documentation": "فتح التوثيق",
|
||||
"Support Chat": "دردشة الدعم",
|
||||
"Domain": "نطاق",
|
||||
"Domain Name": "اسم النطاق",
|
||||
"Domain Verification Code (optional)": "رمز التحقق من النطاق (اختياري)",
|
||||
@@ -879,4 +893,4 @@
|
||||
"results": "نتائج",
|
||||
"selected": "محدد",
|
||||
"to": "إلى"
|
||||
}
|
||||
}
|
||||
|
||||
13
lang/en.json
13
lang/en.json
@@ -782,6 +782,19 @@
|
||||
"Display Name": "Display Name",
|
||||
"Document Root": "Document Root",
|
||||
"Documentation": "Documentation",
|
||||
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.",
|
||||
"Chat with our AI support bot.": "Chat with our AI support bot.",
|
||||
"GitHub Issues": "GitHub Issues",
|
||||
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.",
|
||||
"Open GitHub Issues": "Open GitHub Issues",
|
||||
"Paid Support": "Paid Support",
|
||||
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.",
|
||||
"View Support Plans": "View Support Plans",
|
||||
"Response Time": "Response Time",
|
||||
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.",
|
||||
"Emergency Support": "Emergency Support",
|
||||
"Open Documentation": "Open Documentation",
|
||||
"Support Chat": "Support Chat",
|
||||
"Domain": "Domain",
|
||||
"Domain Aliases": "Domain Aliases",
|
||||
"Domain Certificates": "Domain Certificates",
|
||||
|
||||
16
lang/es.json
16
lang/es.json
@@ -308,6 +308,20 @@
|
||||
"Disk Usage": "Uso de disco",
|
||||
"Display Name": "Nombre para mostrar",
|
||||
"Document Root": "Raíz del documento",
|
||||
"Documentation": "Documentación",
|
||||
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Encuentra respuestas en nuestra documentación o habla con nuestro bot de soporte entrenado. Explora guías de configuración, pasos de solución de problemas y buenas prácticas.",
|
||||
"Chat with our AI support bot.": "Chatea con nuestro bot de soporte con IA.",
|
||||
"GitHub Issues": "Issues de GitHub",
|
||||
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Reporta errores o solicita funciones. Incluye pasos, registros y capturas de pantalla para que podamos reproducirlo rápido.",
|
||||
"Open GitHub Issues": "Abrir issues de GitHub",
|
||||
"Paid Support": "Soporte de pago",
|
||||
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtén asistencia profesional para migraciones, ajustes de rendimiento y correcciones prioritarias. Los planes incluyen incorporación y soporte dedicado.",
|
||||
"View Support Plans": "Ver planes de soporte",
|
||||
"Response Time": "Tiempo de respuesta",
|
||||
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Normalmente respondemos en 4-8 horas. Para incidentes críticos, usa el Soporte de Emergencia para una respuesta más rápida.",
|
||||
"Emergency Support": "Soporte de emergencia",
|
||||
"Open Documentation": "Abrir documentación",
|
||||
"Support Chat": "Chat de soporte",
|
||||
"Domain": "Dominio",
|
||||
"Domain Configuration": "Configuración de dominio",
|
||||
"Domain Count": "Cantidad de dominios",
|
||||
@@ -1170,4 +1184,4 @@
|
||||
"results": "resultados",
|
||||
"selected": "seleccionado(s)",
|
||||
"to": "a"
|
||||
}
|
||||
}
|
||||
|
||||
16
lang/fr.json
16
lang/fr.json
@@ -220,6 +220,20 @@
|
||||
"Disk Usage": "Utilisation du disque",
|
||||
"Display Name": "Nom d'affichage",
|
||||
"Document Root": "Racine du document",
|
||||
"Documentation": "Documentation",
|
||||
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Trouvez des réponses dans notre documentation ou discutez avec notre bot d'assistance entraîné. Explorez les guides de configuration, les étapes de dépannage et les bonnes pratiques.",
|
||||
"Chat with our AI support bot.": "Discutez avec notre bot de support IA.",
|
||||
"GitHub Issues": "Issues GitHub",
|
||||
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Signalez des bugs ou demandez des fonctionnalités. Incluez les étapes, les journaux et des captures d'écran pour une reproduction rapide.",
|
||||
"Open GitHub Issues": "Ouvrir les issues GitHub",
|
||||
"Paid Support": "Support payant",
|
||||
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtenez une assistance professionnelle pour les migrations, l'optimisation des performances et les correctifs prioritaires. Les plans incluent l'onboarding et un support dédié.",
|
||||
"View Support Plans": "Voir les plans de support",
|
||||
"Response Time": "Délai de réponse",
|
||||
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Nous répondons généralement sous 4-8 heures. Pour les incidents critiques, utilisez le support d'urgence pour une réponse plus rapide.",
|
||||
"Emergency Support": "Support d'urgence",
|
||||
"Open Documentation": "Ouvrir la documentation",
|
||||
"Support Chat": "Chat de support",
|
||||
"Domain": "Domaine",
|
||||
"Domain Name": "Nom de domaine",
|
||||
"Domain Verification Code (optional)": "Code de verification du domaine (optionnel)",
|
||||
@@ -886,4 +900,4 @@
|
||||
"results": "resultats",
|
||||
"selected": "selectionne(s)",
|
||||
"to": "a"
|
||||
}
|
||||
}
|
||||
|
||||
16
lang/he.json
16
lang/he.json
@@ -219,6 +219,20 @@
|
||||
"Disk Usage": "שימוש בדיסק",
|
||||
"Display Name": "שם תצוגה",
|
||||
"Document Root": "תיקיית שורש",
|
||||
"Documentation": "תיעוד",
|
||||
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "מצאו תשובות בתיעוד שלנו או דברו עם בוט התמיכה המאומן שלנו. עיינו במדריכי התקנה, שלבי פתרון תקלות ושיטות עבודה מומלצות.",
|
||||
"Chat with our AI support bot.": "שוחחו עם בוט התמיכה שלנו.",
|
||||
"GitHub Issues": "Issues של GitHub",
|
||||
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "דווחו על באגים או בקשו פיצ'רים. צרפו שלבים, לוגים וצילומי מסך כדי שנוכל לשחזר במהירות.",
|
||||
"Open GitHub Issues": "פתח Issues ב-GitHub",
|
||||
"Paid Support": "תמיכה בתשלום",
|
||||
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "קבלו סיוע מקצועי במיגרציות, שיפור ביצועים ותיקונים בעדיפות גבוהה. התוכניות כוללות קליטה ותמיכה ייעודית.",
|
||||
"View Support Plans": "צפו בתוכניות התמיכה",
|
||||
"Response Time": "זמן תגובה",
|
||||
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "אנחנו בדרך כלל מגיבים תוך 4-8 שעות. לאירועים קריטיים, השתמשו בתמיכת חירום לקבלת מענה מהיר יותר.",
|
||||
"Emergency Support": "תמיכת חירום",
|
||||
"Open Documentation": "פתח תיעוד",
|
||||
"Support Chat": "צ'אט תמיכה",
|
||||
"Domain": "דומיין",
|
||||
"Domain Name": "שם הדומיין",
|
||||
"Domain Verification Code (optional)": "קוד אימות דומיין (אופציונלי)",
|
||||
@@ -879,4 +893,4 @@
|
||||
"results": "תוצאות",
|
||||
"selected": "נבחרו",
|
||||
"to": "עד"
|
||||
}
|
||||
}
|
||||
|
||||
16
lang/pt.json
16
lang/pt.json
@@ -219,6 +219,20 @@
|
||||
"Disk Usage": "Uso de Disco",
|
||||
"Display Name": "Nome de Exibição",
|
||||
"Document Root": "Raiz do Documento",
|
||||
"Documentation": "Documentação",
|
||||
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Encontre respostas na nossa documentação ou fale com o nosso bot de suporte treinado. Explore guias de configuração, etapas de solução de problemas e boas práticas.",
|
||||
"Chat with our AI support bot.": "Converse com nosso bot de suporte com IA.",
|
||||
"GitHub Issues": "Issues do GitHub",
|
||||
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Reporte bugs ou solicite recursos. Inclua passos, logs e capturas de tela para podermos reproduzir rapidamente.",
|
||||
"Open GitHub Issues": "Abrir issues do GitHub",
|
||||
"Paid Support": "Suporte pago",
|
||||
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtenha assistência profissional para migrações, ajustes de desempenho e correções prioritárias. Os planos incluem onboarding e suporte dedicado.",
|
||||
"View Support Plans": "Ver planos de suporte",
|
||||
"Response Time": "Tempo de resposta",
|
||||
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Normalmente respondemos em 4-8 horas. Para incidentes críticos, use o Suporte de Emergência para uma resposta mais rápida.",
|
||||
"Emergency Support": "Suporte de emergência",
|
||||
"Open Documentation": "Abrir documentação",
|
||||
"Support Chat": "Chat de suporte",
|
||||
"Domain": "Domínio",
|
||||
"Domain Name": "Nome do Domínio",
|
||||
"Domain Verification Code (optional)": "Código de Verificação do Domínio (opcional)",
|
||||
@@ -879,4 +893,4 @@
|
||||
"results": "resultados",
|
||||
"selected": "selecionado(s)",
|
||||
"to": "até"
|
||||
}
|
||||
}
|
||||
|
||||
16
lang/ru.json
16
lang/ru.json
@@ -220,6 +220,20 @@
|
||||
"Disk Usage": "Использование диска",
|
||||
"Display Name": "Отображаемое имя",
|
||||
"Document Root": "Корневая директория",
|
||||
"Documentation": "Документация",
|
||||
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Найдите ответы в нашей документации или поговорите с нашим обученным ботом поддержки. Изучите руководства по настройке, шаги по устранению неполадок и лучшие практики.",
|
||||
"Chat with our AI support bot.": "Чат с нашим AI-ботом поддержки.",
|
||||
"GitHub Issues": "GitHub Issues",
|
||||
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Сообщайте о багах или запрашивайте функции. Укажите шаги, логи и скриншоты, чтобы мы могли быстро воспроизвести.",
|
||||
"Open GitHub Issues": "Открыть GitHub Issues",
|
||||
"Paid Support": "Платная поддержка",
|
||||
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Профессиональная помощь по миграциям, оптимизации производительности и приоритетным исправлениям. Планы включают онбординг и выделенную поддержку.",
|
||||
"View Support Plans": "Посмотреть планы поддержки",
|
||||
"Response Time": "Время ответа",
|
||||
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Обычно отвечаем в течение 4–8 часов. Для критических инцидентов используйте экстренную поддержку для более быстрого ответа.",
|
||||
"Emergency Support": "Экстренная поддержка",
|
||||
"Open Documentation": "Открыть документацию",
|
||||
"Support Chat": "Чат поддержки",
|
||||
"Domain": "Домен",
|
||||
"Domain Name": "Имя домена",
|
||||
"Domain Verification Code (optional)": "Код подтверждения домена (необязательно)",
|
||||
@@ -886,4 +900,4 @@
|
||||
"results": "результатов",
|
||||
"selected": "выбрано",
|
||||
"to": "до"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
78
resources/views/filament/admin/pages/support.blade.php
Normal file
78
resources/views/filament/admin/pages/support.blade.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<x-filament::section
|
||||
icon="heroicon-o-book-open"
|
||||
icon-color="primary"
|
||||
>
|
||||
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
|
||||
<x-slot name="description">{{ __('Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://jabali-panel.com/docs/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
>
|
||||
{{ __('Open Documentation') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
icon="heroicon-o-bug-ant"
|
||||
icon-color="warning"
|
||||
>
|
||||
<x-slot name="heading">{{ __('GitHub Issues') }}</x-slot>
|
||||
<x-slot name="description">{{ __('Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://github.com/shukiv/jabali-panel/issues"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
color="gray"
|
||||
>
|
||||
{{ __('Open GitHub Issues') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
icon="heroicon-o-lifebuoy"
|
||||
icon-color="primary"
|
||||
>
|
||||
<x-slot name="heading">{{ __('Paid Support') }}</x-slot>
|
||||
<x-slot name="description">{{ __('Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://jabali-panel.com/support/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
>
|
||||
{{ __('View Support Plans') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
icon="heroicon-o-clock"
|
||||
icon-color="gray"
|
||||
compact
|
||||
>
|
||||
<x-slot name="heading">{{ __('Emergency Support') }}</x-slot>
|
||||
<x-slot name="description">{{ __('We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://jabali-panel.com/emergency/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
color="warning"
|
||||
>
|
||||
{{ __('Emergency Support') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,47 @@
|
||||
<x-filament-panels::page>
|
||||
<style>
|
||||
/* Compact spacing for File Manager rows */
|
||||
#file-dropzone .fi-ta-text:not(.fi-inline) {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-text-item {
|
||||
line-height: 1.05rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-record-content-ctn {
|
||||
gap: 0.25rem !important;
|
||||
padding-top: 0.25rem !important;
|
||||
padding-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-record-checkbox {
|
||||
margin-top: 0.2rem !important;
|
||||
margin-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone td.fi-ta-cell.fi-ta-selection-cell,
|
||||
#file-dropzone td.fi-ta-cell.fi-ta-group-selection-cell {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone td.fi-ta-cell:has(.fi-ta-actions) {
|
||||
padding-top: 0.2rem !important;
|
||||
padding-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-actions {
|
||||
gap: 0.35rem !important;
|
||||
}
|
||||
|
||||
#file-dropzone .fi-ta-actions .fi-btn,
|
||||
#file-dropzone .fi-ta-actions .fi-icon-btn {
|
||||
min-height: 1.65rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{-- Warning Banner --}}
|
||||
<x-filament::section
|
||||
icon="heroicon-o-exclamation-triangle"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
|
||||
<x-slot name="heading">{{ __('Activity Log') }}</x-slot>
|
||||
<x-slot name="description">{{ __('Recent actions performed in your account.') }}</x-slot>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Time') }}</th>
|
||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Category') }}</th>
|
||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Action') }}</th>
|
||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Description') }}</th>
|
||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('IP') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@forelse($this->getActivityLogs() as $log)
|
||||
<tr>
|
||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->created_at?->format('Y-m-d H:i') }}</td>
|
||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->category }}</td>
|
||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->action }}</td>
|
||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->description }}</td>
|
||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->ip_address }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-6 text-center fi-section-header-description">
|
||||
{{ __('No activity recorded yet.') }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@@ -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>
|
||||
|
||||
78
resources/views/filament/jabali/pages/support.blade.php
Normal file
78
resources/views/filament/jabali/pages/support.blade.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<x-filament::section
|
||||
icon="heroicon-o-book-open"
|
||||
icon-color="primary"
|
||||
>
|
||||
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
|
||||
<x-slot name="description">{{ __('Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://jabali-panel.com/docs/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
>
|
||||
{{ __('Open Documentation') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
icon="heroicon-o-bug-ant"
|
||||
icon-color="warning"
|
||||
>
|
||||
<x-slot name="heading">{{ __('GitHub Issues') }}</x-slot>
|
||||
<x-slot name="description">{{ __('Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://github.com/shukiv/jabali-panel/issues"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
color="gray"
|
||||
>
|
||||
{{ __('Open GitHub Issues') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
icon="heroicon-o-lifebuoy"
|
||||
icon-color="primary"
|
||||
>
|
||||
<x-slot name="heading">{{ __('Paid Support') }}</x-slot>
|
||||
<x-slot name="description">{{ __('Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://jabali-panel.com/support/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
>
|
||||
{{ __('View Support Plans') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section
|
||||
icon="heroicon-o-clock"
|
||||
icon-color="gray"
|
||||
compact
|
||||
>
|
||||
<x-slot name="heading">{{ __('Emergency Support') }}</x-slot>
|
||||
<x-slot name="description">{{ __('We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.') }}</x-slot>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="https://jabali-panel.com/emergency/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon="heroicon-o-arrow-top-right-on-square"
|
||||
color="warning"
|
||||
>
|
||||
{{ __('Emergency Support') }}
|
||||
</x-filament::button>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
334
scripts/deploy.sh
Executable file
334
scripts/deploy.sh
Executable file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-192.168.100.50}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-root}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/var/www/jabali}"
|
||||
WWW_USER="${WWW_USER:-www-data}"
|
||||
NPM_CACHE_DIR="${NPM_CACHE_DIR:-}"
|
||||
GITEA_REMOTE="${GITEA_REMOTE:-gitea}"
|
||||
GITEA_URL="${GITEA_URL:-}"
|
||||
GITHUB_REMOTE="${GITHUB_REMOTE:-origin}"
|
||||
GITHUB_URL="${GITHUB_URL:-}"
|
||||
PUSH_BRANCH="${PUSH_BRANCH:-}"
|
||||
|
||||
SKIP_SYNC=0
|
||||
SKIP_COMPOSER=0
|
||||
SKIP_NPM=0
|
||||
SKIP_MIGRATE=0
|
||||
SKIP_CACHE=0
|
||||
SKIP_AGENT_RESTART=0
|
||||
DELETE_REMOTE=0
|
||||
DRY_RUN=0
|
||||
PUSH_GITEA=0
|
||||
PUSH_GITHUB=0
|
||||
SET_VERSION=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/deploy.sh [options]
|
||||
|
||||
Options:
|
||||
--host HOST Remote host (default: 192.168.100.50)
|
||||
--user USER SSH user (default: root)
|
||||
--path PATH Remote path (default: /var/www/jabali)
|
||||
--www-user USER Remote runtime user (default: www-data)
|
||||
--skip-sync Skip rsync sync step
|
||||
--skip-composer Skip composer install
|
||||
--skip-npm Skip npm install/build
|
||||
--skip-migrate Skip php artisan migrate
|
||||
--skip-cache Skip cache clear/rebuild
|
||||
--skip-agent-restart Skip restarting jabali-agent service
|
||||
--delete Pass --delete to rsync (dangerous)
|
||||
--dry-run Dry-run rsync only
|
||||
--push-gitea Push current branch to Gitea before deploy
|
||||
--gitea-remote NAME Gitea git remote name (default: gitea)
|
||||
--gitea-url URL Push to this URL instead of a named remote
|
||||
--push-github Push current branch to GitHub before deploy
|
||||
--github-remote NAME GitHub git remote name (default: origin)
|
||||
--github-url URL Push to this URL instead of a named remote
|
||||
--version VALUE Set VERSION to a specific value before push
|
||||
-h, --help Show this help
|
||||
|
||||
Environment overrides:
|
||||
DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, NPM_CACHE_DIR, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--host)
|
||||
DEPLOY_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--user)
|
||||
DEPLOY_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--path)
|
||||
DEPLOY_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--www-user)
|
||||
WWW_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-sync)
|
||||
SKIP_SYNC=1
|
||||
shift
|
||||
;;
|
||||
--skip-composer)
|
||||
SKIP_COMPOSER=1
|
||||
shift
|
||||
;;
|
||||
--skip-npm)
|
||||
SKIP_NPM=1
|
||||
shift
|
||||
;;
|
||||
--skip-migrate)
|
||||
SKIP_MIGRATE=1
|
||||
shift
|
||||
;;
|
||||
--skip-cache)
|
||||
SKIP_CACHE=1
|
||||
shift
|
||||
;;
|
||||
--skip-agent-restart)
|
||||
SKIP_AGENT_RESTART=1
|
||||
shift
|
||||
;;
|
||||
--delete)
|
||||
DELETE_REMOTE=1
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--push-gitea)
|
||||
PUSH_GITEA=1
|
||||
shift
|
||||
;;
|
||||
--gitea-remote)
|
||||
GITEA_REMOTE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--gitea-url)
|
||||
GITEA_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--push-github)
|
||||
PUSH_GITHUB=1
|
||||
shift
|
||||
;;
|
||||
--github-remote)
|
||||
GITHUB_REMOTE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--github-url)
|
||||
GITHUB_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
SET_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
|
||||
ensure_clean_worktree() {
|
||||
if ! git -C "$ROOT_DIR" diff --quiet || ! git -C "$ROOT_DIR" diff --cached --quiet; then
|
||||
echo "Working tree is dirty. Commit or stash changes before pushing."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_current_version() {
|
||||
sed -n 's/^VERSION=//p' "$ROOT_DIR/VERSION"
|
||||
}
|
||||
|
||||
bump_version() {
|
||||
local current new base num
|
||||
current="$(get_current_version)"
|
||||
|
||||
if [[ -n "$SET_VERSION" ]]; then
|
||||
new="$SET_VERSION"
|
||||
else
|
||||
if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then
|
||||
base="${BASH_REMATCH[1]}"
|
||||
num="${BASH_REMATCH[2]}"
|
||||
if [[ -z "$num" ]]; then
|
||||
num=1
|
||||
else
|
||||
num=$((num + 1))
|
||||
fi
|
||||
new="${base}${num}"
|
||||
elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then
|
||||
new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
|
||||
else
|
||||
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$new" == "$current" ]]; then
|
||||
echo "VERSION is already '$current'. Use --version to set a new value."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'VERSION=%s\n' "$new" > "$ROOT_DIR/VERSION"
|
||||
perl -0pi -e "s/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-[^\\\"]+\\}\\\"/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-$new\\}\\\"/g" "$ROOT_DIR/install.sh"
|
||||
|
||||
git -C "$ROOT_DIR" add VERSION install.sh
|
||||
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
|
||||
git -C "$ROOT_DIR" commit -m "Bump VERSION to $new"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_push() {
|
||||
ensure_clean_worktree
|
||||
bump_version
|
||||
}
|
||||
|
||||
push_remote() {
|
||||
local label="$1"
|
||||
local remote_name="$2"
|
||||
local remote_url="$3"
|
||||
local target
|
||||
|
||||
if [[ -n "$remote_url" ]]; then
|
||||
target="$remote_url"
|
||||
else
|
||||
if ! git -C "$ROOT_DIR" remote get-url "$remote_name" >/dev/null 2>&1; then
|
||||
echo "$label remote '$remote_name' not found. Use --${label,,}-url or --${label,,}-remote."
|
||||
exit 1
|
||||
fi
|
||||
target="$remote_name"
|
||||
fi
|
||||
|
||||
if [[ -z "$PUSH_BRANCH" ]]; then
|
||||
PUSH_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)"
|
||||
fi
|
||||
|
||||
git -C "$ROOT_DIR" push "$target" "$PUSH_BRANCH"
|
||||
}
|
||||
|
||||
rsync_project() {
|
||||
local -a rsync_opts
|
||||
rsync_opts=(-az --info=progress2)
|
||||
if [[ "$DELETE_REMOTE" -eq 1 ]]; then
|
||||
rsync_opts+=(--delete)
|
||||
fi
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
rsync_opts+=(--dry-run)
|
||||
fi
|
||||
|
||||
rsync "${rsync_opts[@]}" \
|
||||
--exclude ".git/" \
|
||||
--exclude "node_modules/" \
|
||||
--exclude "vendor/" \
|
||||
--exclude "storage/" \
|
||||
--exclude "bootstrap/cache/" \
|
||||
--exclude "public/build/" \
|
||||
--exclude ".env" \
|
||||
--exclude ".env.*" \
|
||||
--exclude "database/*.sqlite" \
|
||||
--exclude "database/*.sqlite-wal" \
|
||||
--exclude "database/*.sqlite-shm" \
|
||||
"$ROOT_DIR/" \
|
||||
"${REMOTE}:${DEPLOY_PATH}/"
|
||||
}
|
||||
|
||||
remote_run() {
|
||||
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc '$1'"
|
||||
}
|
||||
|
||||
remote_run_www() {
|
||||
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc 'cd \"$DEPLOY_PATH\" && sudo -u \"$WWW_USER\" -H bash -lc \"$1\"'"
|
||||
}
|
||||
|
||||
ensure_remote_permissions() {
|
||||
local parent_dir
|
||||
parent_dir="$(dirname "$DEPLOY_PATH")"
|
||||
|
||||
if [[ -z "$NPM_CACHE_DIR" ]]; then
|
||||
NPM_CACHE_DIR="${parent_dir}/.npm"
|
||||
fi
|
||||
|
||||
remote_run "mkdir -p \"$DEPLOY_PATH/storage\" \"$DEPLOY_PATH/bootstrap/cache\" \"$DEPLOY_PATH/public/build\" \"$DEPLOY_PATH/node_modules\" \"$DEPLOY_PATH/database\" \"$NPM_CACHE_DIR\""
|
||||
remote_run "chown -R \"$WWW_USER\":\"$WWW_USER\" \"$DEPLOY_PATH/storage\" \"$DEPLOY_PATH/bootstrap/cache\" \"$DEPLOY_PATH/public\" \"$DEPLOY_PATH/public/build\" \"$DEPLOY_PATH/node_modules\" \"$DEPLOY_PATH/database\" \"$NPM_CACHE_DIR\""
|
||||
remote_run "if [[ -f \"$DEPLOY_PATH/auth.json\" ]]; then chown \"$WWW_USER\":\"$WWW_USER\" \"$DEPLOY_PATH/auth.json\" && chmod 600 \"$DEPLOY_PATH/auth.json\"; fi"
|
||||
}
|
||||
|
||||
echo "Deploying to ${REMOTE}:${DEPLOY_PATH}"
|
||||
|
||||
if [[ "$PUSH_GITEA" -eq 1 ]]; then
|
||||
prepare_push
|
||||
echo "Pushing to Gitea..."
|
||||
push_remote "Gitea" "$GITEA_REMOTE" "$GITEA_URL"
|
||||
fi
|
||||
|
||||
if [[ "$PUSH_GITHUB" -eq 1 ]]; then
|
||||
if [[ "$PUSH_GITEA" -ne 1 ]]; then
|
||||
prepare_push
|
||||
fi
|
||||
echo "Pushing to GitHub..."
|
||||
push_remote "GitHub" "$GITHUB_REMOTE" "$GITHUB_URL"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_SYNC" -eq 0 ]]; then
|
||||
echo "Syncing project files..."
|
||||
rsync_project
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "Dry run complete. No remote commands executed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Ensuring remote permissions..."
|
||||
ensure_remote_permissions
|
||||
|
||||
if [[ "$SKIP_COMPOSER" -eq 0 ]]; then
|
||||
echo "Installing composer dependencies..."
|
||||
remote_run_www "composer install --no-interaction --prefer-dist --optimize-autoloader"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_NPM" -eq 0 ]]; then
|
||||
echo "Building frontend assets..."
|
||||
remote_run_www "npm ci"
|
||||
remote_run_www "npm run build"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_MIGRATE" -eq 0 ]]; then
|
||||
echo "Running migrations..."
|
||||
remote_run_www "php artisan migrate --force"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_CACHE" -eq 0 ]]; then
|
||||
echo "Refreshing caches..."
|
||||
remote_run_www "php artisan optimize:clear"
|
||||
remote_run_www "php artisan config:cache"
|
||||
remote_run_www "php artisan route:cache"
|
||||
remote_run_www "php artisan view:cache"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_AGENT_RESTART" -eq 0 ]]; then
|
||||
echo "Restarting jabali-agent service..."
|
||||
remote_run "if systemctl list-unit-files jabali-agent.service --no-legend 2>/dev/null | grep -q '^jabali-agent\\.service'; then systemctl restart jabali-agent; fi"
|
||||
fi
|
||||
|
||||
echo "Deploy complete."
|
||||
43
tests/Feature/Filament/SupportPagesTest.php
Normal file
43
tests/Feature/Filament/SupportPagesTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Filament;
|
||||
|
||||
use App\Filament\Admin\Pages\Support as AdminSupport;
|
||||
use App\Filament\Jabali\Pages\Support as UserSupport;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SupportPagesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_admin_support_page_renders_support_links(): void
|
||||
{
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test(AdminSupport::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('Open Documentation')
|
||||
->assertSee('GitHub Issues')
|
||||
->assertSee('Paid Support');
|
||||
}
|
||||
|
||||
public function test_user_support_page_renders_support_links(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(UserSupport::class)
|
||||
->assertStatus(200)
|
||||
->assertSee('Open Documentation')
|
||||
->assertSee('GitHub Issues')
|
||||
->assertSee('Paid Support');
|
||||
}
|
||||
}
|
||||
@@ -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