Compare commits
41 Commits
e95e03c4fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e230ac17aa | |||
|
|
7125c535cc | ||
|
|
2dfc139f42 | ||
|
|
52e116e671 | ||
|
|
0c6402604d | ||
| 5d502699ea | |||
| 967df591d6 | |||
| 2bdf7395fc | |||
| c4acf0b658 | |||
| ed5e3f2bda | |||
| 070e46cf77 | |||
| a566a2ae64 | |||
| 1e66f43d4e | |||
| 443b05a677 | |||
| 13685615cb | |||
| e7920366d7 | |||
| 3fa6399b27 | |||
| e22d73eba5 | |||
| a9f8670224 | |||
| 386c759e70 | |||
| c1599f5dd1 | |||
| 6064de6c81 | |||
| f7902105de | |||
| b049d338d8 | |||
| 8573d96719 | |||
| 800e07d2ba | |||
| c6f5b6cab8 | |||
|
|
8acc55a799 | ||
|
|
a5742a3156 | ||
|
|
66c4be426d | ||
|
|
adc073b751 | ||
|
|
e439204891 | ||
|
|
fd3be5b1cd | ||
|
|
758412168f | ||
|
|
66a1eaba0a | ||
|
|
b7c0419e05 | ||
|
|
6c84704476 | ||
|
|
8df76cdaae | ||
|
|
d8424ad483 | ||
|
|
a0048109ce | ||
|
|
be34afe2c8 |
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
storage/logs
|
||||||
|
storage/framework/cache
|
||||||
|
storage/framework/sessions
|
||||||
|
storage/framework/views
|
||||||
|
storage/app/private
|
||||||
|
storage/app/private/*
|
||||||
|
storage/app/private/livewire-tmp
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
10
.env.example
@@ -29,6 +29,7 @@ SESSION_LIFETIME=120
|
|||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
# SESSION_SECURE_COOKIE=true
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@@ -59,4 +60,13 @@ AWS_DEFAULT_REGION=us-east-1
|
|||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
# Comma-separated list of trusted proxies. Use "*" only when intentional.
|
||||||
|
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||||
|
|
||||||
|
# Optional internal API shared token for non-localhost calls.
|
||||||
|
# JABALI_INTERNAL_API_TOKEN=
|
||||||
|
|
||||||
|
# Set to true only when remote panel migration discovery must skip TLS verification.
|
||||||
|
# JABALI_IMPORT_INSECURE_TLS=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -22,3 +22,6 @@ CLAUDE.md
|
|||||||
/jabali-panel_*.deb
|
/jabali-panel_*.deb
|
||||||
/jabali-deps_*.deb
|
/jabali-deps_*.deb
|
||||||
.git-credentials
|
.git-credentials
|
||||||
|
|
||||||
|
# Local repository configuration (do not commit)
|
||||||
|
config.toml
|
||||||
|
|||||||
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
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"at-rule-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignoreAtRules": [
|
||||||
|
"tailwind",
|
||||||
|
"apply",
|
||||||
|
"layer",
|
||||||
|
"variants",
|
||||||
|
"responsive",
|
||||||
|
"screen",
|
||||||
|
"theme"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
21
AGENT.md
@@ -58,6 +58,7 @@ php artisan route:cache # Cache routes
|
|||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits.
|
**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits.
|
||||||
|
**Important:** Push to GitHub from the test server `root@192.168.100.50` (where the GitHub deploy key is configured).
|
||||||
|
|
||||||
### Version Numbers
|
### Version Numbers
|
||||||
|
|
||||||
@@ -326,6 +327,18 @@ dns.get_ds_records - Get DS records for registrar
|
|||||||
| Admin | `https://jabali.lan/jabali-admin` | `admin@jabali.lan` | `q1w2E#R$` |
|
| Admin | `https://jabali.lan/jabali-admin` | `admin@jabali.lan` | `q1w2E#R$` |
|
||||||
| User | `https://jabali.lan/jabali-panel` | `user@jabali.lan` | `wjqr9t6Z#%r&@C$4` |
|
| User | `https://jabali.lan/jabali-panel` | `user@jabali.lan` | `wjqr9t6Z#%r&@C$4` |
|
||||||
|
|
||||||
|
### Demo Credentials
|
||||||
|
| Panel | URL | Email | Password |
|
||||||
|
|-------|-----|-------|----------|
|
||||||
|
| Admin | `https://demo.jabali-panel.com/jabali-admin` | `admin@jabali-panel.com` | `demo1234` |
|
||||||
|
| User | `https://demo.jabali-panel.com/jabali-panel` | `demo@jabali-panel.com` | `demo1234` |
|
||||||
|
|
||||||
|
**Demo mode behavior**
|
||||||
|
- `JABALI_DEMO=1` enables read-only mode via `App\Http\Middleware\DemoReadOnly`.
|
||||||
|
- Livewire `authenticate` calls are allowed for unauthenticated users.
|
||||||
|
- Some pages use static demo data to avoid agent socket calls.
|
||||||
|
- Reverse proxy must set `X-Forwarded-Proto` and the app must trust proxies for HTTPS Livewire updates.
|
||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
| Model | Table | Description |
|
| Model | Table | Description |
|
||||||
@@ -608,7 +621,15 @@ All administrative actions are logged to the `audit_logs` table.
|
|||||||
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
|
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
|
||||||
- **MUST be responsive** - All pages must work on mobile, tablet, and desktop
|
- **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
|
### Allowed Components
|
||||||
|
|
||||||
Use these Filament native components exclusively:
|
Use these Filament native components exclusively:
|
||||||
|
|
||||||
| Category | Components |
|
| Category | Components |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Rules and behavior for automated agents working on Jabali.
|
|||||||
- Do not push unless the user explicitly asks.
|
- Do not push unless the user explicitly asks.
|
||||||
- Bump `VERSION` before every push.
|
- Bump `VERSION` before every push.
|
||||||
- Keep `install.sh` version fallback in sync with `VERSION`.
|
- Keep `install.sh` version fallback in sync with `VERSION`.
|
||||||
|
- Push to GitHub from `root@192.168.100.50`.
|
||||||
|
|
||||||
## Operational
|
## Operational
|
||||||
- If you add dependencies, update both install and uninstall paths.
|
- If you add dependencies, update both install and uninstall paths.
|
||||||
|
|||||||
12
CONTEXT.md
@@ -1,6 +1,6 @@
|
|||||||
# CONTEXT.md
|
# CONTEXT.md
|
||||||
|
|
||||||
Last updated: 2026-02-01
|
Last updated: 2026-02-03
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Laravel 12, Filament v5, Livewire v4
|
- Laravel 12, Filament v5, Livewire v4
|
||||||
@@ -11,6 +11,16 @@ Last updated: 2026-02-01
|
|||||||
- Admin panel: `/jabali-admin`
|
- Admin panel: `/jabali-admin`
|
||||||
- User panel: `/jabali-panel`
|
- User panel: `/jabali-panel`
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
- Public demo: `https://demo.jabali-panel.com`
|
||||||
|
- Demo container port: `5555` behind Nginx TLS proxy
|
||||||
|
- Demo DB: `database/database-demo.sqlite`
|
||||||
|
- Demo mode: `JABALI_DEMO=1` (read-only)
|
||||||
|
- Demo credentials:
|
||||||
|
- Admin: `admin@jabali-panel.com` / `demo1234`
|
||||||
|
- User: `demo@jabali-panel.com` / `demo1234`
|
||||||
|
- Livewire HTTPS requires proxy trust (`trustProxies(at: '*')`) and `X-Forwarded-Proto`.
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
- Panel config DB: SQLite at `database/database.sqlite`
|
- Panel config DB: SQLite at `database/database.sqlite`
|
||||||
- Hosting services use MariaDB/Postfix/Dovecot/etc. as configured by the agent
|
- Hosting services use MariaDB/Postfix/Dovecot/etc. as configured by the agent
|
||||||
|
|||||||
@@ -6,3 +6,8 @@
|
|||||||
- Asset builds must be writable for `public/build` and `node_modules`; upgrade checks both.
|
- Asset builds must be writable for `public/build` and `node_modules`; upgrade checks both.
|
||||||
- Installer builds assets as `www-data` to avoid permission issues.
|
- Installer builds assets as `www-data` to avoid permission issues.
|
||||||
- Default panel database is SQLite (`database/database.sqlite`).
|
- Default panel database is SQLite (`database/database.sqlite`).
|
||||||
|
|
||||||
|
## 2026-02-03
|
||||||
|
- Demo mode is enforced by `App\Http\Middleware\DemoReadOnly` (read-only, but allow Livewire `authenticate`).
|
||||||
|
- Demo container runs without agent sockets; select pages use static demo data to avoid 500s.
|
||||||
|
- Reverse proxies must be trusted for HTTPS Livewire updates in demo (`trustProxies(at: '*')`).
|
||||||
|
|||||||
98
README.md
@@ -3,22 +3,48 @@
|
|||||||
</p>
|
</p>
|
||||||
<h1 align="center">Jabali Panel</h1>
|
<h1 align="center">Jabali Panel</h1>
|
||||||
|
|
||||||
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 an agent for privileged 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. Administrators get clear visibility into services, SSL, DNS, backups, and security posture, while users get a streamlined workflow for domains, email, WordPress, files, databases, and PHP settings. The goal is simple: reduce the operational burden of hosting by making common tasks safe, repeatable, and easy to audit.
|
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-rc48 (release candidate)
|
Version: see `VERSION` (release candidate)
|
||||||
|
|
||||||
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
||||||
|
|
||||||
|
## Demo and Website
|
||||||
|
|
||||||
|
- Demo: https://jabali-panel.com/demo/
|
||||||
|
- Website: https://jabali-panel.com/
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Per-user Linux accounts and PHP-FPM isolation
|
- Per-user Linux accounts and PHP-FPM isolation
|
||||||
- Root agent for DNS, SSL, mail, backups, and migrations
|
- Root agent for DNS, SSL, mail, backups, and migrations
|
||||||
|
- Health monitor with auto-restarts and alerts
|
||||||
- cPanel and WHM migrations with step-by-step logs
|
- cPanel and WHM migrations with step-by-step logs
|
||||||
- Built-in mail stack with webmail SSO
|
- Built-in mail stack with webmail SSO
|
||||||
- DNS templates with optional DNSSEC
|
- DNS templates with optional DNSSEC
|
||||||
- User and server backups with schedules and retention
|
- User and server backups with schedules and retention
|
||||||
- WordPress management (install, updates, scans, and SSO)
|
- WordPress management (install, updates, scans, and SSO)
|
||||||
- Security center with firewall, Fail2ban, and ClamAV
|
- 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
|
## Installation
|
||||||
|
|
||||||
@@ -33,12 +59,42 @@ Optional flags:
|
|||||||
- `JABALI_MINIMAL=1` for core-only install
|
- `JABALI_MINIMAL=1` for core-only install
|
||||||
- `JABALI_FULL=1` to force all optional components
|
- `JABALI_FULL=1` to force all optional components
|
||||||
|
|
||||||
|
Debian packages:
|
||||||
|
|
||||||
|
```
|
||||||
|
./scripts/build-jabali-deps-deb.sh
|
||||||
|
./scripts/build-jabali-panel-deb.sh
|
||||||
|
sudo dpkg -i ./jabali-deps_<version>_all.deb
|
||||||
|
sudo apt-get -f install -y
|
||||||
|
sudo dpkg -i ./jabali-panel_<version>_all.deb
|
||||||
|
```
|
||||||
|
|
||||||
After install:
|
After install:
|
||||||
|
|
||||||
- Admin panel: `https://your-host/jabali-admin`
|
- Admin panel: `https://your-host/jabali-admin`
|
||||||
- User panel: `https://your-host/jabali-panel`
|
- User panel: `https://your-host/jabali-panel`
|
||||||
- Webmail: `https://your-host/webmail`
|
- Webmail: `https://your-host/webmail`
|
||||||
|
|
||||||
|
Website: https://jabali-panel.com/
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
Public demo:
|
||||||
|
|
||||||
|
- User panel: https://demo.jabali-panel.com/jabali-panel/
|
||||||
|
- Admin panel: https://demo.jabali-panel.com/jabali-admin/
|
||||||
|
|
||||||
|
Credentials:
|
||||||
|
|
||||||
|
- Admin: `admin@jabali-panel.com` / `demo1234`
|
||||||
|
- User: `demo@jabali-panel.com` / `demo1234`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Demo is read-only; actions that change data are blocked.
|
||||||
|
- Some pages use static demo data where the privileged agent is unavailable
|
||||||
|
(for example: PHP Manager, PHP Settings, Protected Directories).
|
||||||
|
|
||||||
## Feature Map
|
## Feature Map
|
||||||
|
|
||||||
### Admin Panel
|
### Admin Panel
|
||||||
@@ -79,33 +135,13 @@ After install:
|
|||||||
- Redis ACL isolation for WordPress caching
|
- Redis ACL isolation for WordPress caching
|
||||||
- Multi-language UI
|
- Multi-language UI
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
Admin panel:
|
|
||||||
|
|
||||||
- Dashboard: 
|
|
||||||
- Server Status: 
|
|
||||||
- Server Settings: 
|
|
||||||
- Security Center: 
|
|
||||||
- Users: 
|
|
||||||
- SSL Manager: 
|
|
||||||
- DNS Zones: 
|
|
||||||
- Backups: 
|
|
||||||
- Services: 
|
|
||||||
|
|
||||||
User panel:
|
|
||||||
|
|
||||||
- Dashboard: 
|
|
||||||
- Domain Management: 
|
|
||||||
- Backups: 
|
|
||||||
- cPanel Migration: 
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- Control plane: Laravel app with Filament panels
|
- Control plane: Laravel 12 app with Filament v5 and Livewire v4
|
||||||
- Data plane: root agent handling privileged operations
|
- Data plane: root agent handling privileged operations
|
||||||
- Job queue: async tasks and migration steps
|
- Job queue: async tasks and migration steps
|
||||||
- Logging: panel and agent logs for troubleshooting
|
- Logging: panel and agent logs for troubleshooting
|
||||||
|
- Server metrics: sysstat logs via SysstatMetrics
|
||||||
|
|
||||||
Service stack (single-node default):
|
Service stack (single-node default):
|
||||||
|
|
||||||
@@ -124,6 +160,13 @@ Service stack (single-node default):
|
|||||||
- PTR (reverse DNS) for mail hostname
|
- PTR (reverse DNS) for mail hostname
|
||||||
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
|
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
|
||||||
|
|
||||||
|
## Security Hardening
|
||||||
|
|
||||||
|
- `TRUSTED_PROXIES`: comma-separated proxy IPs/CIDRs (or `*` if you intentionally trust all upstream proxies).
|
||||||
|
- `JABALI_INTERNAL_API_TOKEN`: optional shared token for internal API calls that do not originate from localhost.
|
||||||
|
- `JABALI_IMPORT_INSECURE_TLS`: optional escape hatch for remote migration discovery. Leave unset for strict TLS verification.
|
||||||
|
- Git deployment webhooks support signed payloads via `X-Jabali-Signature` / `X-Hub-Signature-256` (HMAC-SHA256).
|
||||||
|
|
||||||
## Upgrades
|
## Upgrades
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -158,3 +201,8 @@ php artisan test --compact
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
## Documentation Notes
|
||||||
|
|
||||||
|
- Documentation screenshots are generated for all admin and user pages.
|
||||||
|
- cPanel Migration tabs (Domains, Databases, Mailboxes, Forwarders, SSL) only render after a backup is analyzed. Provide a sample cPanel backup to capture those tab screenshots.
|
||||||
|
|||||||
2
TODO.md
@@ -6,3 +6,5 @@ Keep this list current as work progresses.
|
|||||||
- [ ] Confirm WAF whitelist + blocked requests tables refresh correctly after changes.
|
- [ ] Confirm WAF whitelist + blocked requests tables refresh correctly after changes.
|
||||||
- [ ] Validate sysstat collection interval (10s) and chart intervals align.
|
- [ ] Validate sysstat collection interval (10s) and chart intervals align.
|
||||||
- [ ] Audit installer/uninstaller parity for newly added packages.
|
- [ ] Audit installer/uninstaller parity for newly added packages.
|
||||||
|
- [ ] Demo mode: stub remaining agent-dependent pages to avoid 500s.
|
||||||
|
- [ ] Demo mode: reduce repeated "changes are disabled" notifications for blocked actions.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use Illuminate\Support\Str;
|
|||||||
class ImportProcessCommand extends Command
|
class ImportProcessCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'import:process {import_id : The server import ID to process}';
|
protected $signature = 'import:process {import_id : The server import ID to process}';
|
||||||
|
|
||||||
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
|
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
|
||||||
|
|
||||||
private ?AgentClient $agent = null;
|
private ?AgentClient $agent = null;
|
||||||
@@ -27,8 +28,9 @@ class ImportProcessCommand extends Command
|
|||||||
$importId = (int) $this->argument('import_id');
|
$importId = (int) $this->argument('import_id');
|
||||||
|
|
||||||
$import = ServerImport::with('accounts')->find($importId);
|
$import = ServerImport::with('accounts')->find($importId);
|
||||||
if (!$import) {
|
if (! $import) {
|
||||||
$this->error("Import not found: $importId");
|
$this->error("Import not found: $importId");
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ class ImportProcessCommand extends Command
|
|||||||
'current_task' => null,
|
'current_task' => null,
|
||||||
]);
|
]);
|
||||||
$import->addError('No accounts selected for import');
|
$import->addError('No accounts selected for import');
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +70,8 @@ class ImportProcessCommand extends Command
|
|||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
$account->addLog("Import failed: " . $e->getMessage());
|
$account->addLog('Import failed: '.$e->getMessage());
|
||||||
$import->addError("Account {$account->source_username}: " . $e->getMessage());
|
$import->addError("Account {$account->source_username}: ".$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +99,10 @@ class ImportProcessCommand extends Command
|
|||||||
'completed_at' => now(),
|
'completed_at' => now(),
|
||||||
'progress' => 100,
|
'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;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -107,8 +110,9 @@ class ImportProcessCommand extends Command
|
|||||||
private function getAgent(): AgentClient
|
private function getAgent(): AgentClient
|
||||||
{
|
{
|
||||||
if ($this->agent === null) {
|
if ($this->agent === null) {
|
||||||
$this->agent = new AgentClient();
|
$this->agent = new AgentClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->agent;
|
return $this->agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,28 +136,28 @@ class ImportProcessCommand extends Command
|
|||||||
if ($account->main_domain) {
|
if ($account->main_domain) {
|
||||||
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
|
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
|
||||||
$this->createDomains($account, $user);
|
$this->createDomains($account, $user);
|
||||||
$account->addLog("Created domains");
|
$account->addLog('Created domains');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Import files
|
// Step 3: Import files
|
||||||
if ($options['files'] ?? true) {
|
if ($options['files'] ?? true) {
|
||||||
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
|
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
|
||||||
$this->importFiles($import, $account, $user);
|
$this->importFiles($import, $account, $user);
|
||||||
$account->addLog("Files imported");
|
$account->addLog('Files imported');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Import databases
|
// 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]);
|
$account->update(['current_task' => 'Importing databases...', 'progress' => 60]);
|
||||||
$this->importDatabases($import, $account, $user);
|
$this->importDatabases($import, $account, $user);
|
||||||
$account->addLog("Databases imported");
|
$account->addLog('Databases imported');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Import emails
|
// 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]);
|
$account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]);
|
||||||
$this->importEmails($import, $account, $user);
|
$this->importEmails($import, $account, $user);
|
||||||
$account->addLog("Email accounts imported");
|
$account->addLog('Email accounts imported');
|
||||||
}
|
}
|
||||||
|
|
||||||
$account->update([
|
$account->update([
|
||||||
@@ -161,7 +165,7 @@ class ImportProcessCommand extends Command
|
|||||||
'progress' => 100,
|
'progress' => 100,
|
||||||
'current_task' => null,
|
'current_task' => null,
|
||||||
]);
|
]);
|
||||||
$account->addLog("Import completed successfully");
|
$account->addLog('Import completed successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createUser(ServerImportAccount $account): User
|
private function createUser(ServerImportAccount $account): User
|
||||||
@@ -170,6 +174,7 @@ class ImportProcessCommand extends Command
|
|||||||
$existingUser = User::where('username', $account->target_username)->first();
|
$existingUser = User::where('username', $account->target_username)->first();
|
||||||
if ($existingUser) {
|
if ($existingUser) {
|
||||||
$account->addLog("User already exists: {$account->target_username}");
|
$account->addLog("User already exists: {$account->target_username}");
|
||||||
|
|
||||||
return $existingUser;
|
return $existingUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +184,8 @@ class ImportProcessCommand extends Command
|
|||||||
// Create user via agent
|
// Create user via agent
|
||||||
$result = $this->getAgent()->createUser($account->target_username, $password);
|
$result = $this->getAgent()->createUser($account->target_username, $password);
|
||||||
|
|
||||||
if (!($result['success'] ?? false)) {
|
if (! ($result['success'] ?? false)) {
|
||||||
throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error'));
|
throw new Exception('Failed to create system user: '.($result['error'] ?? 'Unknown error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user in database
|
// Create user in database
|
||||||
@@ -191,7 +196,7 @@ class ImportProcessCommand extends Command
|
|||||||
'password' => Hash::make($password),
|
'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;
|
return $user;
|
||||||
}
|
}
|
||||||
@@ -201,19 +206,19 @@ class ImportProcessCommand extends Command
|
|||||||
// Create main domain
|
// Create main domain
|
||||||
if ($account->main_domain) {
|
if ($account->main_domain) {
|
||||||
$existingDomain = Domain::where('domain', $account->main_domain)->first();
|
$existingDomain = Domain::where('domain', $account->main_domain)->first();
|
||||||
if (!$existingDomain) {
|
if (! $existingDomain) {
|
||||||
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
|
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
Domain::create([
|
Domain::create([
|
||||||
'domain' => $account->main_domain,
|
'domain' => $account->main_domain,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public",
|
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public_html",
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
$account->addLog("Created main domain: {$account->main_domain}");
|
$account->addLog("Created main domain: {$account->main_domain}");
|
||||||
} else {
|
} else {
|
||||||
$account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown'));
|
$account->addLog('Warning: Failed to create main domain: '.($result['error'] ?? 'Unknown'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$account->addLog("Main domain already exists: {$account->main_domain}");
|
$account->addLog("Main domain already exists: {$account->main_domain}");
|
||||||
@@ -223,14 +228,14 @@ class ImportProcessCommand extends Command
|
|||||||
// Create addon domains
|
// Create addon domains
|
||||||
foreach ($account->addon_domains ?? [] as $domain) {
|
foreach ($account->addon_domains ?? [] as $domain) {
|
||||||
$existingDomain = Domain::where('domain', $domain)->first();
|
$existingDomain = Domain::where('domain', $domain)->first();
|
||||||
if (!$existingDomain) {
|
if (! $existingDomain) {
|
||||||
$result = $this->getAgent()->domainCreate($user->username, $domain);
|
$result = $this->getAgent()->domainCreate($user->username, $domain);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
Domain::create([
|
Domain::create([
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'document_root' => "/home/{$user->username}/domains/{$domain}/public",
|
'document_root' => "/home/{$user->username}/domains/{$domain}/public_html",
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
$account->addLog("Created addon domain: {$domain}");
|
$account->addLog("Created addon domain: {$domain}");
|
||||||
@@ -243,31 +248,38 @@ class ImportProcessCommand extends Command
|
|||||||
|
|
||||||
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
|
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
|
||||||
{
|
{
|
||||||
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
|
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
|
||||||
$account->addLog("File import skipped - not a backup file import");
|
$account->addLog('File import skipped - not a backup file import');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupPath = Storage::disk('local')->path($import->backup_path);
|
$backupPath = $this->resolveBackupFullPath($import);
|
||||||
if (!file_exists($backupPath)) {
|
if (! $backupPath) {
|
||||||
$account->addLog("Warning: Backup file not found");
|
$account->addLog('Warning: Backup file not found');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$extractDir = "/tmp/import_{$import->id}_{$account->id}_" . time();
|
$extractDir = "/tmp/import_{$import->id}_{$account->id}_".time();
|
||||||
if (!mkdir($extractDir, 0755, true)) {
|
if (! mkdir($extractDir, 0755, true)) {
|
||||||
$account->addLog("Warning: Failed to create extraction directory");
|
$account->addLog('Warning: Failed to create extraction directory');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$username = $account->source_username;
|
$username = $account->source_username;
|
||||||
|
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
|
||||||
|
|
||||||
if ($import->source_type === 'cpanel') {
|
if ($import->source_type === 'cpanel') {
|
||||||
// Extract home directory from cPanel backup
|
// 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";
|
" --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null";
|
||||||
exec($cmd, $output, $code);
|
exec($cmd, $output, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
$account->addLog('Warning: Failed to extract backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
// Find extracted files
|
// Find extracted files
|
||||||
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
|
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
|
||||||
@@ -278,19 +290,22 @@ class ImportProcessCommand extends Command
|
|||||||
// Copy public_html to the domain
|
// Copy public_html to the domain
|
||||||
$publicHtml = "$homeDir/public_html";
|
$publicHtml = "$homeDir/public_html";
|
||||||
if (is_dir($publicHtml) && $account->main_domain) {
|
if (is_dir($publicHtml) && $account->main_domain) {
|
||||||
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public";
|
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public_html";
|
||||||
if (is_dir($destDir)) {
|
if (is_dir($destDir)) {
|
||||||
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . 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");
|
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||||
$account->addLog("Copied public_html to {$account->main_domain}");
|
$account->addLog("Copied public_html to {$account->main_domain}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Extract from DirectAdmin backup
|
// 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";
|
" --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null";
|
||||||
exec($cmd, $output, $code);
|
exec($cmd, $output, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
$account->addLog('Warning: Failed to extract DirectAdmin backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
// Find domain directories
|
// Find domain directories
|
||||||
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
|
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
|
||||||
@@ -301,10 +316,10 @@ class ImportProcessCommand extends Command
|
|||||||
$publicHtml = "$domainDir/public_html";
|
$publicHtml = "$domainDir/public_html";
|
||||||
|
|
||||||
if (is_dir($publicHtml)) {
|
if (is_dir($publicHtml)) {
|
||||||
$destDir = "/home/{$user->username}/domains/{$domain}/public";
|
$destDir = "/home/{$user->username}/domains/{$domain}/public_html";
|
||||||
if (is_dir($destDir)) {
|
if (is_dir($destDir)) {
|
||||||
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . 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");
|
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||||
$account->addLog("Copied files for domain: {$domain}");
|
$account->addLog("Copied files for domain: {$domain}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,54 +327,97 @@ class ImportProcessCommand extends Command
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Cleanup
|
// Cleanup
|
||||||
exec("rm -rf " . escapeshellarg($extractDir));
|
exec('rm -rf '.escapeshellarg($extractDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
|
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
|
||||||
{
|
{
|
||||||
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
|
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
|
||||||
$account->addLog("Database import skipped - not a backup file import");
|
$account->addLog('Database import skipped - not a backup file import');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupPath = Storage::disk('local')->path($import->backup_path);
|
$backupPath = $this->resolveBackupFullPath($import);
|
||||||
if (!file_exists($backupPath)) {
|
if (! $backupPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_" . time();
|
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_".time();
|
||||||
if (!mkdir($extractDir, 0755, true)) {
|
if (! mkdir($extractDir, 0755, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
|
||||||
|
|
||||||
// Extract MySQL dumps
|
// Extract MySQL dumps
|
||||||
if ($import->source_type === 'cpanel') {
|
if ($import->source_type === 'cpanel') {
|
||||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||||
" --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null";
|
" --wildcards '*/mysql/*.sql*' 'mysql/*.sql*' 2>/dev/null";
|
||||||
} else {
|
} else {
|
||||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||||
" --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null";
|
" --wildcards 'backup/databases/*.sql*' 'databases/*.sql*' 2>/dev/null";
|
||||||
}
|
}
|
||||||
exec($cmd, $output, $code);
|
exec($cmd, $output, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
$account->addLog('Warning: Failed to extract database dumps from backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
// Find SQL files
|
// Find SQL files
|
||||||
$sqlFiles = [];
|
$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) {
|
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
|
// 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
|
// Create database via agent
|
||||||
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
|
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
|
$sqlToImport = $sqlFile;
|
||||||
|
$tmpSql = null;
|
||||||
|
|
||||||
|
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
|
// Import data
|
||||||
$cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1";
|
$cmd = 'mysql '.escapeshellarg($newDbName).' < '.escapeshellarg($sqlToImport).' 2>&1';
|
||||||
exec($cmd, $importOutput, $importCode);
|
exec($cmd, $importOutput, $importCode);
|
||||||
|
|
||||||
if ($importCode === 0) {
|
if ($importCode === 0) {
|
||||||
@@ -367,12 +425,17 @@ class ImportProcessCommand extends Command
|
|||||||
} else {
|
} else {
|
||||||
$account->addLog("Warning: Database created but import failed: {$newDbName}");
|
$account->addLog("Warning: Database created but import failed: {$newDbName}");
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if ($tmpSql && file_exists($tmpSql)) {
|
||||||
|
@unlink($tmpSql);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$account->addLog("Warning: Failed to create database: {$newDbName}");
|
$account->addLog("Warning: Failed to create database: {$newDbName}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} 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("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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,23 @@ use App\Models\User;
|
|||||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||||
use Filament\Auth\Pages\Login as BaseLogin;
|
use Filament\Auth\Pages\Login as BaseLogin;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class Login extends BaseLogin
|
class Login extends BaseLogin
|
||||||
{
|
{
|
||||||
|
public function getSubheading(): string | HtmlString | null
|
||||||
|
{
|
||||||
|
if (env('JABALI_DEMO', false)) {
|
||||||
|
return new HtmlString(
|
||||||
|
__('Demo credentials') .
|
||||||
|
': <code>admin@jabali-panel.com</code> / <code>demo1234</code>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::getSubheading();
|
||||||
|
}
|
||||||
|
|
||||||
public function authenticate(): ?LoginResponse
|
public function authenticate(): ?LoginResponse
|
||||||
{
|
{
|
||||||
$data = $this->form->getState();
|
$data = $this->form->getState();
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ class Backups extends Page implements HasActions, HasForms, HasTable
|
|||||||
->color('gray'),
|
->color('gray'),
|
||||||
TextColumn::make('duration')
|
TextColumn::make('duration')
|
||||||
->label(__('Duration'))
|
->label(__('Duration'))
|
||||||
->placeholder('-')
|
->placeholder(__('-'))
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
|
|||||||
@@ -574,7 +574,7 @@ class CpanelMigration extends Page implements HasActions, HasForms, HasInfolists
|
|||||||
Grid::make(['default' => 1, 'sm' => 2])->schema([
|
Grid::make(['default' => 1, 'sm' => 2])->schema([
|
||||||
TextInput::make('hostname')
|
TextInput::make('hostname')
|
||||||
->label(__('cPanel Hostname'))
|
->label(__('cPanel Hostname'))
|
||||||
->placeholder('cpanel.example.com')
|
->placeholder(__('cpanel.example.com'))
|
||||||
->required(fn () => $this->sourceType === 'remote')
|
->required(fn () => $this->sourceType === 'remote')
|
||||||
->helperText(__('Your cPanel server hostname or IP address')),
|
->helperText(__('Your cPanel server hostname or IP address')),
|
||||||
TextInput::make('port')
|
TextInput::make('port')
|
||||||
@@ -610,7 +610,7 @@ class CpanelMigration extends Page implements HasActions, HasForms, HasInfolists
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('localBackupPath')
|
TextInput::make('localBackupPath')
|
||||||
->label(__('Backup File Path'))
|
->label(__('Backup File Path'))
|
||||||
->placeholder('/home/user/backups/backup-date_username.tar.gz')
|
->placeholder(__('/home/user/backups/backup-date_username.tar.gz'))
|
||||||
->required(fn () => $this->sourceType === 'local')
|
->required(fn () => $this->sourceType === 'local')
|
||||||
->helperText(__('Full path to the cPanel backup file (e.g., /var/backups/backup.tar.gz)')),
|
->helperText(__('Full path to the cPanel backup file (e.g., /var/backups/backup.tar.gz)')),
|
||||||
Text::make(__('Supported formats: .tar.gz, .tgz'))->color('gray'),
|
Text::make(__('Supported formats: .tar.gz, .tgz'))->color('gray'),
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ use Filament\Actions\Contracts\HasActions;
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\EmbeddedTable;
|
use Filament\Schemas\Components\EmbeddedTable;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Text;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
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
|
protected function getForms(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -78,27 +88,86 @@ class Dashboard extends Page implements HasActions, HasForms
|
|||||||
->color('gray')
|
->color('gray')
|
||||||
->action(fn () => $this->redirect(request()->url())),
|
->action(fn () => $this->redirect(request()->url())),
|
||||||
|
|
||||||
Action::make('onboarding')
|
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
|
||||||
->label(__('Setup Wizard'))
|
->label(__('Setup Wizard'))
|
||||||
->icon('heroicon-o-sparkles')
|
->icon('heroicon-o-sparkles')
|
||||||
->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
|
|
||||||
->modalHeading(__('Welcome to Jabali!'))
|
->modalHeading(__('Welcome to Jabali!'))
|
||||||
->modalDescription(__('Let\'s get your server control panel set up.'))
|
->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([
|
->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')
|
TextInput::make('admin_email')
|
||||||
->label(__('Your Email Address'))
|
->label(__('Your Email Address'))
|
||||||
->helperText(__('Enter your email to receive important server notifications.'))
|
->helperText(__('Enter your email to receive important server notifications.'))
|
||||||
->email()
|
->email()
|
||||||
->placeholder('admin@example.com'),
|
->placeholder(__('admin@example.com')),
|
||||||
])
|
])
|
||||||
->modalSubmitActionLabel(__('Get Started'))
|
->modalSubmitActionLabel(__('Save and close'))
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
if (! empty($data['admin_email'])) {
|
$adminEmail = trim((string) ($data['admin_email'] ?? ''));
|
||||||
DnsSetting::set('admin_email_recipients', $data['admin_email']);
|
|
||||||
|
if ($adminEmail !== '') {
|
||||||
|
DnsSetting::set('admin_email_recipients', $adminEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
DnsSetting::set('onboarding_completed', '1');
|
DnsSetting::set('onboarding_completed', '1');
|
||||||
DnsSetting::clearCache();
|
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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -254,12 +254,12 @@ class DnsZones extends Page implements HasActions, HasForms, HasTable
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('priority')
|
TextColumn::make('priority')
|
||||||
->label(__('Priority'))
|
->label(__('Priority'))
|
||||||
->placeholder('-')
|
->placeholder(__('-'))
|
||||||
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
|
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('domain.user.username')
|
TextColumn::make('domain.user.username')
|
||||||
->label(__('Owner'))
|
->label(__('Owner'))
|
||||||
->placeholder('N/A')
|
->placeholder(__('N/A'))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class IpAddresses extends Page implements HasActions, HasTable
|
|||||||
->form([
|
->form([
|
||||||
TextInput::make('ip')
|
TextInput::make('ip')
|
||||||
->label(__('IP Address'))
|
->label(__('IP Address'))
|
||||||
->placeholder('203.0.113.10')
|
->placeholder(__('203.0.113.10'))
|
||||||
->live()
|
->live()
|
||||||
->afterStateUpdated(function (?string $state, callable $set): void {
|
->afterStateUpdated(function (?string $state, callable $set): void {
|
||||||
if (! $state) {
|
if (! $state) {
|
||||||
@@ -198,7 +198,7 @@ class IpAddresses extends Page implements HasActions, HasTable
|
|||||||
->getStateUsing(fn (array $record): ?string => $this->getDefaultLabel($record))
|
->getStateUsing(fn (array $record): ?string => $this->getDefaultLabel($record))
|
||||||
->badge()
|
->badge()
|
||||||
->color('success')
|
->color('success')
|
||||||
->placeholder('-'),
|
->placeholder(__('-')),
|
||||||
])
|
])
|
||||||
->recordActions([
|
->recordActions([
|
||||||
Action::make('setDefault')
|
Action::make('setDefault')
|
||||||
|
|||||||
@@ -41,19 +41,19 @@ class Migration extends Page implements HasForms
|
|||||||
|
|
||||||
public function getSubheading(): ?string
|
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
if (! in_array($this->activeTab, ['cpanel', 'whm'], true)) {
|
if (! in_array($this->activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
|
||||||
$this->activeTab = 'cpanel';
|
$this->activeTab = 'cpanel';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedActiveTab(string $activeTab): void
|
public function updatedActiveTab(string $activeTab): void
|
||||||
{
|
{
|
||||||
if (! in_array($activeTab, ['cpanel', 'whm'], true)) {
|
if (! in_array($activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
|
||||||
$this->activeTab = 'cpanel';
|
$this->activeTab = 'cpanel';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,11 @@ class Migration extends Page implements HasForms
|
|||||||
->schema([
|
->schema([
|
||||||
View::make('filament.admin.pages.migration-whm-tab'),
|
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'),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,30 @@ class PhpManager extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
public function loadPhpVersions(): void
|
public function loadPhpVersions(): void
|
||||||
{
|
{
|
||||||
|
if ((bool) env('JABALI_DEMO', false)) {
|
||||||
|
$this->installedVersions = [
|
||||||
|
['version' => '8.4', 'fpm_status' => 'active'],
|
||||||
|
['version' => '8.3', 'fpm_status' => 'active'],
|
||||||
|
['version' => '8.2', 'fpm_status' => 'inactive'],
|
||||||
|
['version' => '8.1', 'fpm_status' => 'inactive'],
|
||||||
|
];
|
||||||
|
$this->defaultVersion = '8.4';
|
||||||
|
$allVersions = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'];
|
||||||
|
$installed = array_column($this->installedVersions, 'version');
|
||||||
|
$this->availableVersions = array_diff($allVersions, $installed);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$result = $this->getAgent()->send('php.list_versions', []);
|
$result = $this->getAgent()->send('php.list_versions', []);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->installedVersions = [];
|
||||||
|
$this->defaultVersion = null;
|
||||||
|
$this->availableVersions = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
$this->installedVersions = $result['versions'] ?? [];
|
$this->installedVersions = $result['versions'] ?? [];
|
||||||
|
|||||||
@@ -575,7 +575,9 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
->visible(fn () => $this->fail2banRunning)
|
->visible(fn () => $this->fail2banRunning)
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(__('Disable Fail2ban'))
|
->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'),
|
->action('disableFail2ban'),
|
||||||
])
|
])
|
||||||
->schema([
|
->schema([
|
||||||
@@ -665,9 +667,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
|
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
|
||||||
->size('sm')
|
->size('sm')
|
||||||
->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav())
|
->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
|
->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?')),
|
: __('Starting ClamAV daemon uses ~500MB RAM. Continue?')),
|
||||||
FormAction::make('updateSignatures')
|
FormAction::make('updateSignatures')
|
||||||
->label(__('Update Signatures'))
|
->label(__('Update Signatures'))
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
Grid::make(['default' => 1, 'md' => 2])->schema([
|
Grid::make(['default' => 1, 'md' => 2])->schema([
|
||||||
TextInput::make('brandingData.panel_name')
|
TextInput::make('brandingData.panel_name')
|
||||||
->label(__('Control Panel Name'))
|
->label(__('Control Panel Name'))
|
||||||
->placeholder('Jabali')
|
->placeholder(__('Jabali'))
|
||||||
->helperText(__('Appears in browser title and navigation'))
|
->helperText(__('Appears in browser title and navigation'))
|
||||||
->required(),
|
->required(),
|
||||||
]),
|
]),
|
||||||
@@ -320,7 +320,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('hostnameData.hostname')
|
TextInput::make('hostnameData.hostname')
|
||||||
->label(__('Hostname'))
|
->label(__('Hostname'))
|
||||||
->placeholder('server.example.com')
|
->placeholder(__('server.example.com'))
|
||||||
->required(),
|
->required(),
|
||||||
Actions::make([
|
Actions::make([
|
||||||
FormAction::make('saveHostname')
|
FormAction::make('saveHostname')
|
||||||
@@ -338,10 +338,10 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
->icon('heroicon-o-server-stack')
|
->icon('heroicon-o-server-stack')
|
||||||
->schema([
|
->schema([
|
||||||
Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([
|
Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([
|
||||||
TextInput::make('dnsData.ns1')->label(__('NS1 Hostname'))->placeholder('ns1.example.com'),
|
TextInput::make('dnsData.ns1')->label(__('NS1 Hostname'))->placeholder(__('ns1.example.com')),
|
||||||
TextInput::make('dnsData.ns1_ip')->label(__('NS1 IP Address'))->placeholder('192.168.1.1'),
|
TextInput::make('dnsData.ns1_ip')->label(__('NS1 IP Address'))->placeholder(__('192.168.1.1')),
|
||||||
TextInput::make('dnsData.ns2')->label(__('NS2 Hostname'))->placeholder('ns2.example.com'),
|
TextInput::make('dnsData.ns2')->label(__('NS2 Hostname'))->placeholder(__('ns2.example.com')),
|
||||||
TextInput::make('dnsData.ns2_ip')->label(__('NS2 IP Address'))->placeholder('192.168.1.2'),
|
TextInput::make('dnsData.ns2_ip')->label(__('NS2 IP Address'))->placeholder(__('192.168.1.2')),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
Section::make(__('Zone Defaults'))
|
Section::make(__('Zone Defaults'))
|
||||||
@@ -349,20 +349,20 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
Grid::make(['default' => 1, 'md' => 3])->schema([
|
Grid::make(['default' => 1, 'md' => 3])->schema([
|
||||||
TextInput::make('dnsData.default_ip')
|
TextInput::make('dnsData.default_ip')
|
||||||
->label(__('Default Server IP'))
|
->label(__('Default Server IP'))
|
||||||
->placeholder('192.168.1.1')
|
->placeholder(__('192.168.1.1'))
|
||||||
->helperText(__('Default A record IP for new zones')),
|
->helperText(__('Default A record IP for new zones')),
|
||||||
TextInput::make('dnsData.default_ipv6')
|
TextInput::make('dnsData.default_ipv6')
|
||||||
->label(__('Default IPv6'))
|
->label(__('Default IPv6'))
|
||||||
->placeholder('2001:db8::1')
|
->placeholder(__('2001:db8::1'))
|
||||||
->helperText(__('Default AAAA record IP for new zones'))
|
->helperText(__('Default AAAA record IP for new zones'))
|
||||||
->rule('nullable|ipv6'),
|
->rule('nullable|ipv6'),
|
||||||
TextInput::make('dnsData.default_ttl')
|
TextInput::make('dnsData.default_ttl')
|
||||||
->label(__('Default TTL'))
|
->label(__('Default TTL'))
|
||||||
->placeholder('3600'),
|
->placeholder(__('3600')),
|
||||||
]),
|
]),
|
||||||
TextInput::make('dnsData.admin_email')
|
TextInput::make('dnsData.admin_email')
|
||||||
->label(__('Admin Email (SOA)'))
|
->label(__('Admin Email (SOA)'))
|
||||||
->placeholder('admin.example.com')
|
->placeholder(__('admin.example.com'))
|
||||||
->helperText(__('Use dots instead of @ (e.g., admin.example.com)')),
|
->helperText(__('Use dots instead of @ (e.g., admin.example.com)')),
|
||||||
Actions::make([
|
Actions::make([
|
||||||
FormAction::make('saveDns')
|
FormAction::make('saveDns')
|
||||||
@@ -386,10 +386,10 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
->action('applyQuad9Resolvers'),
|
->action('applyQuad9Resolvers'),
|
||||||
])->alignment('left'),
|
])->alignment('left'),
|
||||||
Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([
|
Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([
|
||||||
TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder('8.8.8.8'),
|
TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder(__('8.8.8.8')),
|
||||||
TextInput::make('resolversData.resolver2')->label(__('Resolver 2'))->placeholder('8.8.4.4'),
|
TextInput::make('resolversData.resolver2')->label(__('Resolver 2'))->placeholder(__('8.8.4.4')),
|
||||||
TextInput::make('resolversData.resolver3')->label(__('Resolver 3'))->placeholder('1.1.1.1'),
|
TextInput::make('resolversData.resolver3')->label(__('Resolver 3'))->placeholder(__('1.1.1.1')),
|
||||||
TextInput::make('resolversData.search_domain')->label(__('Search Domain'))->placeholder('example.com'),
|
TextInput::make('resolversData.search_domain')->label(__('Search Domain'))->placeholder(__('example.com')),
|
||||||
]),
|
]),
|
||||||
Actions::make([
|
Actions::make([
|
||||||
FormAction::make('saveResolvers')
|
FormAction::make('saveResolvers')
|
||||||
@@ -470,7 +470,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
TextInput::make('quotaData.default_quota_mb')
|
TextInput::make('quotaData.default_quota_mb')
|
||||||
->label(__('Default Quota (MB)'))
|
->label(__('Default Quota (MB)'))
|
||||||
->numeric()
|
->numeric()
|
||||||
->placeholder('5120')
|
->placeholder(__('5120'))
|
||||||
->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')),
|
->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')),
|
||||||
]),
|
]),
|
||||||
Actions::make([
|
Actions::make([
|
||||||
@@ -487,7 +487,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
->numeric()
|
->numeric()
|
||||||
->minValue(1)
|
->minValue(1)
|
||||||
->maxValue(500)
|
->maxValue(500)
|
||||||
->placeholder('100')
|
->placeholder(__('100'))
|
||||||
->helperText(__('Maximum file size users can upload (1-500 MB)')),
|
->helperText(__('Maximum file size users can upload (1-500 MB)')),
|
||||||
Actions::make([
|
Actions::make([
|
||||||
FormAction::make('saveFileManagerSettings')
|
FormAction::make('saveFileManagerSettings')
|
||||||
@@ -507,7 +507,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
Grid::make(['default' => 1, 'md' => 2])->schema([
|
Grid::make(['default' => 1, 'md' => 2])->schema([
|
||||||
TextInput::make('emailData.mail_hostname')
|
TextInput::make('emailData.mail_hostname')
|
||||||
->label(__('Mail Server Hostname'))
|
->label(__('Mail Server Hostname'))
|
||||||
->placeholder('mail.example.com')
|
->placeholder(__('mail.example.com'))
|
||||||
->helperText(__('The hostname used for mail server identification')),
|
->helperText(__('The hostname used for mail server identification')),
|
||||||
TextInput::make('emailData.mail_default_quota_mb')
|
TextInput::make('emailData.mail_default_quota_mb')
|
||||||
->label(__('Default Mailbox Quota (MB)'))
|
->label(__('Default Mailbox Quota (MB)'))
|
||||||
@@ -527,11 +527,11 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
Grid::make(['default' => 1, 'md' => 2])->schema([
|
Grid::make(['default' => 1, 'md' => 2])->schema([
|
||||||
TextInput::make('emailData.webmail_url')
|
TextInput::make('emailData.webmail_url')
|
||||||
->label(__('Webmail URL'))
|
->label(__('Webmail URL'))
|
||||||
->placeholder('/webmail')
|
->placeholder(__('/webmail'))
|
||||||
->helperText(__('URL path for Roundcube webmail')),
|
->helperText(__('URL path for Roundcube webmail')),
|
||||||
TextInput::make('emailData.webmail_product_name')
|
TextInput::make('emailData.webmail_product_name')
|
||||||
->label(__('Webmail Product Name'))
|
->label(__('Webmail Product Name'))
|
||||||
->placeholder('Jabali Webmail')
|
->placeholder(__('Jabali Webmail'))
|
||||||
->helperText(__('Name displayed on the webmail login page')),
|
->helperText(__('Name displayed on the webmail login page')),
|
||||||
]),
|
]),
|
||||||
Actions::make([
|
Actions::make([
|
||||||
@@ -556,7 +556,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('notificationsData.admin_email_recipients')
|
TextInput::make('notificationsData.admin_email_recipients')
|
||||||
->label(__('Email Addresses'))
|
->label(__('Email Addresses'))
|
||||||
->placeholder('admin@example.com, alerts@example.com')
|
->placeholder(__('admin@example.com, alerts@example.com'))
|
||||||
->helperText(__('Comma-separated list of email addresses to receive notifications')),
|
->helperText(__('Comma-separated list of email addresses to receive notifications')),
|
||||||
]),
|
]),
|
||||||
Section::make(__('Notification Types & High Load Alerts'))
|
Section::make(__('Notification Types & High Load Alerts'))
|
||||||
@@ -598,14 +598,14 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
->minValue(1)
|
->minValue(1)
|
||||||
->maxValue(100)
|
->maxValue(100)
|
||||||
->step(0.5)
|
->step(0.5)
|
||||||
->placeholder('5')
|
->placeholder(__('5'))
|
||||||
->helperText(__('Alert when load exceeds this value')),
|
->helperText(__('Alert when load exceeds this value')),
|
||||||
TextInput::make('notificationsData.load_alert_minutes')
|
TextInput::make('notificationsData.load_alert_minutes')
|
||||||
->label(__('Alert After (minutes)'))
|
->label(__('Alert After (minutes)'))
|
||||||
->numeric()
|
->numeric()
|
||||||
->minValue(1)
|
->minValue(1)
|
||||||
->maxValue(60)
|
->maxValue(60)
|
||||||
->placeholder('5')
|
->placeholder(__('5'))
|
||||||
->helperText(__('Minutes of high load before alerting')),
|
->helperText(__('Minutes of high load before alerting')),
|
||||||
]),
|
]),
|
||||||
Actions::make([
|
Actions::make([
|
||||||
@@ -649,7 +649,7 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
->helperText(__('Requests before worker recycle')),
|
->helperText(__('Requests before worker recycle')),
|
||||||
TextInput::make('phpFpmData.memory_limit')
|
TextInput::make('phpFpmData.memory_limit')
|
||||||
->label(__('Memory Limit'))
|
->label(__('Memory Limit'))
|
||||||
->placeholder('512M')
|
->placeholder(__('512M'))
|
||||||
->helperText(__('PHP memory_limit (e.g., 512M, 1G)')),
|
->helperText(__('PHP memory_limit (e.g., 512M, 1G)')),
|
||||||
]),
|
]),
|
||||||
Grid::make(['default' => 1, 'md' => 2, 'lg' => 3])->schema([
|
Grid::make(['default' => 1, 'md' => 2, 'lg' => 3])->schema([
|
||||||
@@ -692,17 +692,17 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
protected function databaseTabContent(): array
|
protected function databaseTabContent(): array
|
||||||
{
|
{
|
||||||
return [
|
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'))
|
Section::make(__('Database Tuning'))
|
||||||
->description(__('Adjust MariaDB/MySQL global variables.'))
|
->description(__('Adjust MariaDB/MySQL global variables.'))
|
||||||
->icon('heroicon-o-circle-stack')
|
->icon('heroicon-o-circle-stack')
|
||||||
->schema([
|
->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),
|
EmbeddedTable::make(DatabaseTuningTable::class),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
|
|||||||
->visible(fn (array $record): bool => $record['is_active'])
|
->visible(fn (array $record): bool => $record['is_active'])
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(__('Stop Service'))
|
->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'))
|
->modalSubmitActionLabel(__('Stop Service'))
|
||||||
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
|
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
|
||||||
Action::make('restart')
|
Action::make('restart')
|
||||||
@@ -236,7 +238,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
|
|||||||
->visible(fn (array $record): bool => $record['is_enabled'])
|
->visible(fn (array $record): bool => $record['is_enabled'])
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(__('Disable Service'))
|
->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'))
|
->modalSubmitActionLabel(__('Disable Service'))
|
||||||
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
|
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class SslManager extends Page implements HasTable
|
|||||||
->limit(30)
|
->limit(30)
|
||||||
->tooltip(fn ($state) => $state)
|
->tooltip(fn ($state) => $state)
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->placeholder('-'),
|
->placeholder(__('-')),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('ssl_status')
|
SelectFilter::make('ssl_status')
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -382,7 +382,7 @@ class WhmMigration extends Page implements HasActions, HasForms, HasInfolists, H
|
|||||||
Grid::make(['default' => 1, 'sm' => 2])->schema([
|
Grid::make(['default' => 1, 'sm' => 2])->schema([
|
||||||
TextInput::make('hostname')
|
TextInput::make('hostname')
|
||||||
->label(__('WHM Hostname'))
|
->label(__('WHM Hostname'))
|
||||||
->placeholder('whm.example.com')
|
->placeholder(__('whm.example.com'))
|
||||||
->required()
|
->required()
|
||||||
->helperText(__('Your WHM server hostname or IP address')),
|
->helperText(__('Your WHM server hostname or IP address')),
|
||||||
TextInput::make('port')
|
TextInput::make('port')
|
||||||
|
|||||||
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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class DnsPendingAddsTable extends Component implements HasActions, HasSchemas, H
|
|||||||
->label(__('TTL')),
|
->label(__('TTL')),
|
||||||
TextColumn::make('priority')
|
TextColumn::make('priority')
|
||||||
->label(__('Priority'))
|
->label(__('Priority'))
|
||||||
->placeholder('-'),
|
->placeholder(__('-')),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('removePending')
|
Action::make('removePending')
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class Fail2banLogsTable extends Component implements HasActions, HasSchemas, Has
|
|||||||
->label(__('IP'))
|
->label(__('IP'))
|
||||||
->fontFamily('mono')
|
->fontFamily('mono')
|
||||||
->copyable()
|
->copyable()
|
||||||
->placeholder('-'),
|
->placeholder(__('-')),
|
||||||
TextColumn::make('message')
|
TextColumn::make('message')
|
||||||
->label(__('Message'))
|
->label(__('Message'))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
|
|||||||
@@ -12,9 +12,22 @@ use Filament\Facades\Filament;
|
|||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Illuminate\Contracts\Auth\Guard;
|
use Illuminate\Contracts\Auth\Guard;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
|
|
||||||
class Login extends BaseLogin
|
class Login extends BaseLogin
|
||||||
{
|
{
|
||||||
|
public function getSubheading(): string | HtmlString | null
|
||||||
|
{
|
||||||
|
if (env('JABALI_DEMO', false)) {
|
||||||
|
return new HtmlString(
|
||||||
|
__('Demo credentials') .
|
||||||
|
': <code>demo@jabali-panel.com</code> / <code>demo1234</code>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::getSubheading();
|
||||||
|
}
|
||||||
|
|
||||||
public function authenticate(): ?LoginResponse
|
public function authenticate(): ?LoginResponse
|
||||||
{
|
{
|
||||||
$panel = Filament::getPanel('jabali');
|
$panel = Filament::getPanel('jabali');
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ class Backups extends Page implements HasActions, HasForms, HasTable
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('duration')
|
TextColumn::make('duration')
|
||||||
->label(__('Duration'))
|
->label(__('Duration'))
|
||||||
->placeholder('-'),
|
->placeholder(__('-')),
|
||||||
])
|
])
|
||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->emptyStateHeading(__('No restore history'))
|
->emptyStateHeading(__('No restore history'))
|
||||||
@@ -1092,7 +1092,7 @@ class Backups extends Page implements HasActions, HasForms, HasTable
|
|||||||
Grid::make(2)->schema([
|
Grid::make(2)->schema([
|
||||||
TextInput::make('host')
|
TextInput::make('host')
|
||||||
->label(__('Host'))
|
->label(__('Host'))
|
||||||
->placeholder('backup.example.com')
|
->placeholder(__('backup.example.com'))
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('port')
|
TextInput::make('port')
|
||||||
->label(__('Port'))
|
->label(__('Port'))
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class CpanelMigration extends Page implements HasActions, HasForms
|
|||||||
|
|
||||||
protected static ?string $navigationLabel = null;
|
protected static ?string $navigationLabel = null;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
return __('cPanel Migration');
|
return __('cPanel Migration');
|
||||||
@@ -571,7 +573,7 @@ class CpanelMigration extends Page implements HasActions, HasForms
|
|||||||
Grid::make(['default' => 1, 'sm' => 2])->schema([
|
Grid::make(['default' => 1, 'sm' => 2])->schema([
|
||||||
TextInput::make('hostname')
|
TextInput::make('hostname')
|
||||||
->label(__('cPanel Hostname'))
|
->label(__('cPanel Hostname'))
|
||||||
->placeholder('cpanel.example.com')
|
->placeholder(__('cpanel.example.com'))
|
||||||
->required()
|
->required()
|
||||||
->helperText(__('Your cPanel server hostname or IP address')),
|
->helperText(__('Your cPanel server hostname or IP address')),
|
||||||
TextInput::make('port')
|
TextInput::make('port')
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ use App\Filament\Jabali\Widgets\DomainsWidget;
|
|||||||
use App\Filament\Jabali\Widgets\MailboxesWidget;
|
use App\Filament\Jabali\Widgets\MailboxesWidget;
|
||||||
use App\Filament\Jabali\Widgets\RecentBackupsWidget;
|
use App\Filament\Jabali\Widgets\RecentBackupsWidget;
|
||||||
use App\Filament\Jabali\Widgets\StatsOverview;
|
use App\Filament\Jabali\Widgets\StatsOverview;
|
||||||
|
use App\Models\DnsSetting;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Pages\Dashboard as BaseDashboard;
|
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;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class Dashboard extends BaseDashboard
|
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
|
public function getSubheading(): ?string
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -288,7 +288,7 @@ class DnsRecords extends Page implements HasActions, HasForms, HasTable
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('priority')
|
TextColumn::make('priority')
|
||||||
->label(__('Priority'))
|
->label(__('Priority'))
|
||||||
->placeholder('-')
|
->placeholder(__('-'))
|
||||||
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
|
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
|
||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ class Domains extends Page implements HasActions, HasForms, HasTable
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('domain_redirect_url')
|
TextInput::make('domain_redirect_url')
|
||||||
->label(__('Redirect To'))
|
->label(__('Redirect To'))
|
||||||
->placeholder('https://newdomain.com')
|
->placeholder(__('https://newdomain.com'))
|
||||||
->helperText(__('All requests to this domain will be redirected to this URL'))
|
->helperText(__('All requests to this domain will be redirected to this URL'))
|
||||||
->url()
|
->url()
|
||||||
->required(fn ($get) => $get('domain_redirect_enabled'))
|
->required(fn ($get) => $get('domain_redirect_enabled'))
|
||||||
@@ -275,13 +275,13 @@ class Domains extends Page implements HasActions, HasForms, HasTable
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('source_path')
|
TextInput::make('source_path')
|
||||||
->label(__('Source Path'))
|
->label(__('Source Path'))
|
||||||
->placeholder('/old-page')
|
->placeholder(__('/old-page'))
|
||||||
->helperText(__('Path to redirect from (e.g., /old-page)'))
|
->helperText(__('Path to redirect from (e.g., /old-page)'))
|
||||||
->required()
|
->required()
|
||||||
->columnSpan(['default' => 2, 'md' => 1]),
|
->columnSpan(['default' => 2, 'md' => 1]),
|
||||||
TextInput::make('destination_url')
|
TextInput::make('destination_url')
|
||||||
->label(__('Destination URL'))
|
->label(__('Destination URL'))
|
||||||
->placeholder('https://example.com/new-page')
|
->placeholder(__('https://example.com/new-page'))
|
||||||
->helperText(__('Full URL to redirect to'))
|
->helperText(__('Full URL to redirect to'))
|
||||||
->required()
|
->required()
|
||||||
->url()
|
->url()
|
||||||
@@ -359,13 +359,13 @@ class Domains extends Page implements HasActions, HasForms, HasTable
|
|||||||
Textarea::make('allowed_domains')
|
Textarea::make('allowed_domains')
|
||||||
->label(__('Allowed Domains'))
|
->label(__('Allowed Domains'))
|
||||||
->helperText(__('One domain per line that can link to your files (your own domain is always allowed)'))
|
->helperText(__('One domain per line that can link to your files (your own domain is always allowed)'))
|
||||||
->placeholder("example.com\ntrusted-site.com")
|
->placeholder(__("example.com\ntrusted-site.com"))
|
||||||
->rows(4)
|
->rows(4)
|
||||||
->columnSpan(['default' => 2, 'md' => 1]),
|
->columnSpan(['default' => 2, 'md' => 1]),
|
||||||
TextInput::make('protected_extensions')
|
TextInput::make('protected_extensions')
|
||||||
->label(__('Protected File Extensions'))
|
->label(__('Protected File Extensions'))
|
||||||
->helperText(__('Comma-separated list of file extensions to protect'))
|
->helperText(__('Comma-separated list of file extensions to protect'))
|
||||||
->placeholder('jpg,jpeg,png,gif,webp,svg,mp4,mp3,pdf')
|
->placeholder(__('jpg,jpeg,png,gif,webp,svg,mp4,mp3,pdf'))
|
||||||
->default(DomainHotlinkSetting::getDefaultExtensions())
|
->default(DomainHotlinkSetting::getDefaultExtensions())
|
||||||
->columnSpan(['default' => 2, 'md' => 1]),
|
->columnSpan(['default' => 2, 'md' => 1]),
|
||||||
])
|
])
|
||||||
@@ -381,7 +381,7 @@ class Domains extends Page implements HasActions, HasForms, HasTable
|
|||||||
TextInput::make('redirect_url')
|
TextInput::make('redirect_url')
|
||||||
->label(__('Redirect URL (Optional)'))
|
->label(__('Redirect URL (Optional)'))
|
||||||
->helperText(__('Redirect blocked requests to this URL instead of showing an error'))
|
->helperText(__('Redirect blocked requests to this URL instead of showing an error'))
|
||||||
->placeholder('https://example.com/hotlink-blocked.png')
|
->placeholder(__('https://example.com/hotlink-blocked.png'))
|
||||||
->url()
|
->url()
|
||||||
->columnSpan(['default' => 2, 'md' => 1]),
|
->columnSpan(['default' => 2, 'md' => 1]),
|
||||||
])
|
])
|
||||||
@@ -842,7 +842,7 @@ class Domains extends Page implements HasActions, HasForms, HasTable
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('alias')
|
TextInput::make('alias')
|
||||||
->label(__('Alias Domain'))
|
->label(__('Alias Domain'))
|
||||||
->placeholder('alias-example.com')
|
->placeholder(__('alias-example.com'))
|
||||||
->required()
|
->required()
|
||||||
->rule('regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\\.[a-z]{2,}$/')
|
->rule('regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\\.[a-z]{2,}$/')
|
||||||
->helperText(__('Enter a full domain name.')),
|
->helperText(__('Enter a full domain name.')),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use Filament\Pages\Page;
|
|||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs;
|
use Filament\Schemas\Components\Tabs;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\View;
|
use Filament\Schemas\Components\View;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
@@ -185,11 +186,11 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
Textarea::make('whitelist')
|
Textarea::make('whitelist')
|
||||||
->label(__('Whitelist (one per line)'))
|
->label(__('Whitelist (one per line)'))
|
||||||
->rows(6)
|
->rows(6)
|
||||||
->placeholder("friend@example.com\ntrusted.com"),
|
->placeholder(__("friend@example.com\ntrusted.com")),
|
||||||
Textarea::make('blacklist')
|
Textarea::make('blacklist')
|
||||||
->label(__('Blacklist (one per line)'))
|
->label(__('Blacklist (one per line)'))
|
||||||
->rows(6)
|
->rows(6)
|
||||||
->placeholder("spam@example.com\nbad-domain.com"),
|
->placeholder(__("spam@example.com\nbad-domain.com")),
|
||||||
TextInput::make('score')
|
TextInput::make('score')
|
||||||
->label(__('Spam Score Threshold'))
|
->label(__('Spam Score Threshold'))
|
||||||
->numeric()
|
->numeric()
|
||||||
@@ -1017,21 +1018,26 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
->label(__('Domain'))
|
->label(__('Domain'))
|
||||||
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->live()
|
||||||
|
->live(),
|
||||||
TextInput::make('local_part')
|
TextInput::make('local_part')
|
||||||
->label(__('Email Address'))
|
->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._%+-]+$/')
|
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
||||||
->maxLength(64)
|
->maxLength(64)
|
||||||
->helperText(__('The part before the @ symbol')),
|
->helperText(__('The part before the @ symbol')),
|
||||||
TextInput::make('name')
|
TextInput::make('name')
|
||||||
->label(__('Display Name'))
|
->label(__('Display Name'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('password')
|
TextInput::make('password')
|
||||||
->label(__('Password'))
|
->label(__('Password'))
|
||||||
->password()
|
->password()
|
||||||
->revealable()
|
->revealable()
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->minLength(8)
|
->minLength(8)
|
||||||
->rules([
|
->rules([
|
||||||
'regex:/[a-z]/', // lowercase
|
'regex:/[a-z]/', // lowercase
|
||||||
@@ -1063,6 +1069,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
TextInput::make('quota_mb')
|
TextInput::make('quota_mb')
|
||||||
->label(__('Quota (MB)'))
|
->label(__('Quota (MB)'))
|
||||||
->numeric()
|
->numeric()
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->default(1024)
|
->default(1024)
|
||||||
->minValue(100)
|
->minValue(100)
|
||||||
->maxValue(10240)
|
->maxValue(10240)
|
||||||
@@ -1236,16 +1243,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
->label(__('Domain'))
|
->label(__('Domain'))
|
||||||
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->live(),
|
||||||
TextInput::make('local_part')
|
TextInput::make('local_part')
|
||||||
->label(__('Email Address'))
|
->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._%+-]+$/')
|
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
||||||
->maxLength(64)
|
->maxLength(64)
|
||||||
->helperText(__('The part before the @ symbol')),
|
->helperText(__('The part before the @ symbol')),
|
||||||
TextInput::make('destinations')
|
TextInput::make('destinations')
|
||||||
->label(__('Forward To'))
|
->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')),
|
->helperText(__('Comma-separated email addresses to forward to')),
|
||||||
])
|
])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
public function getTitle(): string|Htmlable
|
public function getTitle(): string|Htmlable
|
||||||
{
|
{
|
||||||
return 'File Manager';
|
return __('File Manager');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAgent(): AgentClient
|
public function getAgent(): AgentClient
|
||||||
@@ -83,7 +83,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
// Invalid path from URL - reset to home directory
|
// Invalid path from URL - reset to home directory
|
||||||
$this->currentPath = '';
|
$this->currentPath = '';
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Invalid path')
|
->title(__('Invalid path'))
|
||||||
->body('The requested path is not allowed.')
|
->body('The requested path is not allowed.')
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@@ -222,7 +222,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->items = [];
|
$this->items = [];
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Error loading directory')
|
->title(__('Error loading directory'))
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@@ -237,7 +237,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Invalid path')
|
->title(__('Invalid path'))
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@@ -425,7 +425,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
->form([
|
->form([
|
||||||
TextInput::make('mode')
|
TextInput::make('mode')
|
||||||
->label(__('Numeric Mode'))
|
->label(__('Numeric Mode'))
|
||||||
->placeholder('755')
|
->placeholder(__('755'))
|
||||||
->maxLength(4)
|
->maxLength(4)
|
||||||
->helperText(__('Enter octal mode (e.g., 755, 644)')),
|
->helperText(__('Enter octal mode (e.g., 755, 644)')),
|
||||||
Grid::make(3)
|
Grid::make(3)
|
||||||
@@ -750,7 +750,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->getAgent()->fileMove($this->getUsername(), $sourcePath, $destPath);
|
$this->getAgent()->fileMove($this->getUsername(), $sourcePath, $destPath);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Item moved successfully')
|
->title(__('Item moved successfully'))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@@ -758,7 +758,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Error moving item')
|
->title(__('Error moving item'))
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@@ -788,7 +788,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
);
|
);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title("Uploaded: $filename")
|
->title(__('Uploaded: :filename', ['filename' => $filename]))
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@@ -796,7 +796,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title("Upload failed: $filename")
|
->title(__('Upload failed: :filename', ['filename' => $filename]))
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@@ -939,7 +939,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
$uploaded++;
|
$uploaded++;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('Upload failed: ').$file->getClientOriginalName())
|
->title(__('Upload failed: :filename', ['filename' => $file->getClientOriginalName()]))
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@@ -1007,7 +1007,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
filename: basename($path)
|
filename: basename($path)
|
||||||
);
|
);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Notification::make()->title('Error downloading')->body($e->getMessage())->danger()->send();
|
Notification::make()->title(__('Error downloading'))->body($e->getMessage())->danger()->send();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
protected function getWebhookUrl(GitDeploymentModel $deployment): string
|
protected function getWebhookUrl(GitDeploymentModel $deployment): string
|
||||||
{
|
{
|
||||||
return url("/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}");
|
return url("/api/webhooks/git/{$deployment->id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getDeployKey(): string
|
protected function getDeployKey(): string
|
||||||
@@ -162,6 +162,11 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
|||||||
->rows(2)
|
->rows(2)
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false),
|
->dehydrated(false),
|
||||||
|
TextInput::make('webhook_secret')
|
||||||
|
->label(__('Webhook Secret'))
|
||||||
|
->helperText(__('Set this as your provider webhook secret. Jabali validates HMAC-SHA256 signatures.'))
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false),
|
||||||
Textarea::make('deploy_key')
|
Textarea::make('deploy_key')
|
||||||
->label(__('Deploy Key'))
|
->label(__('Deploy Key'))
|
||||||
->rows(3)
|
->rows(3)
|
||||||
@@ -170,6 +175,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
|||||||
])
|
])
|
||||||
->fillForm(fn (GitDeploymentModel $record): array => [
|
->fillForm(fn (GitDeploymentModel $record): array => [
|
||||||
'webhook_url' => $this->getWebhookUrl($record),
|
'webhook_url' => $this->getWebhookUrl($record),
|
||||||
|
'webhook_secret' => $record->secret_token,
|
||||||
'deploy_key' => $this->getDeployKey(),
|
'deploy_key' => $this->getDeployKey(),
|
||||||
]),
|
]),
|
||||||
Action::make('edit')
|
Action::make('edit')
|
||||||
@@ -276,7 +282,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
|||||||
}),
|
}),
|
||||||
TextInput::make('repo_url')
|
TextInput::make('repo_url')
|
||||||
->label(__('Repository URL'))
|
->label(__('Repository URL'))
|
||||||
->placeholder('git@github.com:org/repo.git')
|
->placeholder(__('git@github.com:org/repo.git'))
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('branch')
|
TextInput::make('branch')
|
||||||
->label(__('Branch'))
|
->label(__('Branch'))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Jabali\Pages;
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
use App\Filament\Jabali\Widgets\ActivityLogTable;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@@ -16,6 +16,7 @@ use Filament\Notifications\Notification;
|
|||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\Tabs;
|
use Filament\Schemas\Components\Tabs;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Filament\Schemas\Components\EmbeddedTable;
|
||||||
use Filament\Schemas\Components\View;
|
use Filament\Schemas\Components\View;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
@@ -119,7 +120,7 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
'activity' => Tab::make(__('Activity Log'))
|
'activity' => Tab::make(__('Activity Log'))
|
||||||
->icon('heroicon-o-clipboard-document-list')
|
->icon('heroicon-o-clipboard-document-list')
|
||||||
->schema([
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getActivityLogs()
|
|
||||||
{
|
|
||||||
return AuditLog::query()
|
|
||||||
->where('user_id', Auth::id())
|
|
||||||
->latest()
|
|
||||||
->limit(50)
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generateStats(): void
|
public function generateStats(): void
|
||||||
{
|
{
|
||||||
if (! $this->selectedDomain) {
|
if (! $this->selectedDomain) {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class MailingLists extends Page implements HasActions, HasForms
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('listmonk_url')
|
TextInput::make('listmonk_url')
|
||||||
->label(__('Listmonk URL'))
|
->label(__('Listmonk URL'))
|
||||||
->placeholder('https://lists.example.com')
|
->placeholder(__('https://lists.example.com'))
|
||||||
->url()
|
->url()
|
||||||
->visible(fn ($get) => $get('provider') === 'listmonk'),
|
->visible(fn ($get) => $get('provider') === 'listmonk'),
|
||||||
TextInput::make('listmonk_token')
|
TextInput::make('listmonk_token')
|
||||||
@@ -99,7 +99,7 @@ class MailingLists extends Page implements HasActions, HasForms
|
|||||||
->schema([
|
->schema([
|
||||||
TextInput::make('mailman_url')
|
TextInput::make('mailman_url')
|
||||||
->label(__('Mailman URL'))
|
->label(__('Mailman URL'))
|
||||||
->placeholder('https://lists.example.com/mailman')
|
->placeholder(__('https://lists.example.com/mailman'))
|
||||||
->url()
|
->url()
|
||||||
->visible(fn ($get) => $get('provider') === 'mailman'),
|
->visible(fn ($get) => $get('provider') === 'mailman'),
|
||||||
TextInput::make('mailman_admin')
|
TextInput::make('mailman_admin')
|
||||||
|
|||||||
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'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,16 @@ class PhpSettings extends Page implements HasActions, HasForms
|
|||||||
|
|
||||||
protected function loadDomains(): void
|
protected function loadDomains(): void
|
||||||
{
|
{
|
||||||
|
if ((bool) env('JABALI_DEMO', false)) {
|
||||||
|
$this->domains = [
|
||||||
|
['domain' => 'jabali-panel.com'],
|
||||||
|
['domain' => 'demo-site.com'],
|
||||||
|
['domain' => 'store.demo'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->getAgent()->send('domain.list', [
|
$result = $this->getAgent()->send('domain.list', [
|
||||||
'username' => $this->getUsername(),
|
'username' => $this->getUsername(),
|
||||||
]);
|
]);
|
||||||
@@ -92,6 +102,17 @@ class PhpSettings extends Page implements HasActions, HasForms
|
|||||||
|
|
||||||
protected function loadPhpVersions(): void
|
protected function loadPhpVersions(): void
|
||||||
{
|
{
|
||||||
|
if ((bool) env('JABALI_DEMO', false)) {
|
||||||
|
$this->phpVersions = [
|
||||||
|
'8.4' => 'PHP 8.4',
|
||||||
|
'8.3' => 'PHP 8.3',
|
||||||
|
'8.2' => 'PHP 8.2',
|
||||||
|
'8.1' => 'PHP 8.1',
|
||||||
|
];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->getAgent()->send('php.list_versions', []);
|
$result = $this->getAgent()->send('php.list_versions', []);
|
||||||
|
|
||||||
$this->phpVersions = [];
|
$this->phpVersions = [];
|
||||||
@@ -120,6 +141,20 @@ class PhpSettings extends Page implements HasActions, HasForms
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((bool) env('JABALI_DEMO', false)) {
|
||||||
|
$this->data = [
|
||||||
|
'php_version' => array_key_first($this->phpVersions),
|
||||||
|
'memory_limit' => '256M',
|
||||||
|
'upload_max_filesize' => '64M',
|
||||||
|
'post_max_size' => '64M',
|
||||||
|
'max_input_vars' => '3000',
|
||||||
|
'max_execution_time' => '300',
|
||||||
|
'max_input_time' => '300',
|
||||||
|
];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->getAgent()->send('php.getSettings', [
|
$result = $this->getAgent()->send('php.getSettings', [
|
||||||
'domain' => $this->selectedDomain,
|
'domain' => $this->selectedDomain,
|
||||||
'username' => $this->getUsername(),
|
'username' => $this->getUsername(),
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ class ProtectedDirectories extends Page implements HasActions, HasForms, HasTabl
|
|||||||
|
|
||||||
protected function loadDomains(): void
|
protected function loadDomains(): void
|
||||||
{
|
{
|
||||||
|
if ((bool) env('JABALI_DEMO', false)) {
|
||||||
|
$this->domains = [
|
||||||
|
['domain' => 'jabali-panel.com'],
|
||||||
|
['domain' => 'demo-site.com'],
|
||||||
|
['domain' => 'store.demo'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->getAgent()->send('domain.list', [
|
$result = $this->getAgent()->send('domain.list', [
|
||||||
'username' => $this->getUsername(),
|
'username' => $this->getUsername(),
|
||||||
]);
|
]);
|
||||||
@@ -102,6 +112,30 @@ class ProtectedDirectories extends Page implements HasActions, HasForms, HasTabl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((bool) env('JABALI_DEMO', false)) {
|
||||||
|
$this->protectedDirs = [
|
||||||
|
[
|
||||||
|
'path' => '/admin',
|
||||||
|
'name' => 'Restricted Area',
|
||||||
|
'users_count' => 2,
|
||||||
|
'users' => [
|
||||||
|
['username' => 'demo', 'created_at' => now()->subDays(10)->toDateTimeString()],
|
||||||
|
['username' => 'editor', 'created_at' => now()->subDays(3)->toDateTimeString()],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'path' => '/private',
|
||||||
|
'name' => 'Private Files',
|
||||||
|
'users_count' => 1,
|
||||||
|
'users' => [
|
||||||
|
['username' => 'staff', 'created_at' => now()->subDays(1)->toDateTimeString()],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->getAgent()->send('domain.list_protected_dirs', [
|
$result = $this->getAgent()->send('domain.list_protected_dirs', [
|
||||||
'domain' => $this->selectedDomain,
|
'domain' => $this->selectedDomain,
|
||||||
'username' => $this->getUsername(),
|
'username' => $this->getUsername(),
|
||||||
@@ -239,7 +273,7 @@ class ProtectedDirectories extends Page implements HasActions, HasForms, HasTabl
|
|||||||
->form([
|
->form([
|
||||||
TextInput::make('path')
|
TextInput::make('path')
|
||||||
->label(__('Directory Path'))
|
->label(__('Directory Path'))
|
||||||
->placeholder('/admin')
|
->placeholder(__('/admin'))
|
||||||
->required()
|
->required()
|
||||||
->helperText(__('Path relative to your document root (e.g., /admin, /private, /members)')),
|
->helperText(__('Path relative to your document root (e.g., /admin, /private, /members)')),
|
||||||
TextInput::make('name')
|
TextInput::make('name')
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class Ssl extends Page implements HasActions, HasForms, HasTable
|
|||||||
? __('Expired :days days ago', ['days' => abs($record->sslCertificate->days_until_expiry)])
|
? __('Expired :days days ago', ['days' => abs($record->sslCertificate->days_until_expiry)])
|
||||||
: __(':days days left', ['days' => $record->sslCertificate->days_until_expiry]))
|
: __(':days days left', ['days' => $record->sslCertificate->days_until_expiry]))
|
||||||
: null)
|
: null)
|
||||||
->placeholder('-')
|
->placeholder(__('-'))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
IconColumn::make('sslCertificate.auto_renew')
|
IconColumn::make('sslCertificate.auto_renew')
|
||||||
->label(__('Auto-Renew'))
|
->label(__('Auto-Renew'))
|
||||||
@@ -421,19 +421,19 @@ class Ssl extends Page implements HasActions, HasForms, HasTable
|
|||||||
->helperText(__('Select the domain to install the certificate on')),
|
->helperText(__('Select the domain to install the certificate on')),
|
||||||
Textarea::make('certificate')
|
Textarea::make('certificate')
|
||||||
->label(__('Certificate (PEM format)'))
|
->label(__('Certificate (PEM format)'))
|
||||||
->placeholder("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----")
|
->placeholder(__("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"))
|
||||||
->rows(8)
|
->rows(8)
|
||||||
->required()
|
->required()
|
||||||
->helperText(__('Paste your SSL certificate in PEM format')),
|
->helperText(__('Paste your SSL certificate in PEM format')),
|
||||||
Textarea::make('private_key')
|
Textarea::make('private_key')
|
||||||
->label(__('Private Key (PEM format)'))
|
->label(__('Private Key (PEM format)'))
|
||||||
->placeholder("-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----")
|
->placeholder(__("-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"))
|
||||||
->rows(8)
|
->rows(8)
|
||||||
->required()
|
->required()
|
||||||
->helperText(__('Paste your private key in PEM format. Keep this secure!')),
|
->helperText(__('Paste your private key in PEM format. Keep this secure!')),
|
||||||
Textarea::make('ca_bundle')
|
Textarea::make('ca_bundle')
|
||||||
->label(__('CA Bundle (optional)'))
|
->label(__('CA Bundle (optional)'))
|
||||||
->placeholder("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----")
|
->placeholder(__("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"))
|
||||||
->rows(6)
|
->rows(6)
|
||||||
->helperText(__('Paste the certificate authority chain if required by your certificate provider')),
|
->helperText(__('Paste the certificate authority chain if required by your certificate provider')),
|
||||||
])
|
])
|
||||||
|
|||||||
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;
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
|
use App\Models\DnsRecord;
|
||||||
|
use App\Models\DnsSetting;
|
||||||
use App\Models\MysqlCredential;
|
use App\Models\MysqlCredential;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@@ -22,6 +24,7 @@ use Filament\Infolists\Components\TextEntry;
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Columns\ViewColumn;
|
use Filament\Tables\Columns\ViewColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
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.'))
|
->modalDescription(__('This will create a copy of your site for testing.'))
|
||||||
->modalIcon('heroicon-o-document-duplicate')
|
->modalIcon('heroicon-o-document-duplicate')
|
||||||
->modalIconColor('info')
|
->modalIconColor('info')
|
||||||
->form([
|
->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')
|
TextInput::make('staging_subdomain')
|
||||||
->label(__('Staging Subdomain'))
|
->label(__('Subdomain'))
|
||||||
->prefix('staging-')
|
|
||||||
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
|
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
|
||||||
->default('test')
|
->default('test')
|
||||||
->required()
|
->required(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
|
||||||
->alphaNum(),
|
->visible(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
|
||||||
])
|
->regex('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/')
|
||||||
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
|
->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')
|
Action::make('pushStaging')
|
||||||
->label(__('Push to Production'))
|
->label(__('Push to Production'))
|
||||||
->icon('heroicon-o-arrow-up-tray')
|
->icon('heroicon-o-arrow-up-tray')
|
||||||
@@ -258,6 +291,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
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
|
// Delete screenshot if exists
|
||||||
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
|
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
|
||||||
if (file_exists($screenshotPath)) {
|
if (file_exists($screenshotPath)) {
|
||||||
@@ -448,24 +492,29 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
->options($domainOptions)
|
->options($domainOptions)
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->searchable()
|
||||||
|
->live()
|
||||||
->placeholder(__('Select a domain...'))
|
->placeholder(__('Select a domain...'))
|
||||||
->helperText(__('The domain where WordPress will be installed')),
|
->helperText(__('The domain where WordPress will be installed')),
|
||||||
Toggle::make('use_www')
|
Toggle::make('use_www')
|
||||||
->label(__('Use www prefix'))
|
->label(__('Use www prefix'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->helperText(__('Install on www.domain.com instead of domain.com'))
|
->helperText(__('Install on www.domain.com instead of domain.com'))
|
||||||
->default(false),
|
->default(false),
|
||||||
TextInput::make('path')
|
TextInput::make('path')
|
||||||
->label(__('Directory (optional)'))
|
->label(__('Directory (optional)'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->placeholder(__('Leave empty to install in root'))
|
->placeholder(__('Leave empty to install in root'))
|
||||||
->helperText(__('e.g., "blog" to install at domain.com/blog')),
|
->helperText(__('e.g., "blog" to install at domain.com/blog')),
|
||||||
TextInput::make('site_title')
|
TextInput::make('site_title')
|
||||||
->label(__('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'))
|
->default(__('My WordPress Site'))
|
||||||
->helperText(__('The name of your WordPress site')),
|
->helperText(__('The name of your WordPress site')),
|
||||||
TextInput::make('admin_user')
|
TextInput::make('admin_user')
|
||||||
->label(__('Admin Username'))
|
->label(__('Admin Username'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->default('admin')
|
->default('admin')
|
||||||
->alphaNum()
|
->alphaNum()
|
||||||
->helperText(__('Username for the WordPress admin account')),
|
->helperText(__('Username for the WordPress admin account')),
|
||||||
@@ -473,7 +522,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
->label(__('Admin Password'))
|
->label(__('Admin Password'))
|
||||||
->password()
|
->password()
|
||||||
->revealable()
|
->revealable()
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->default(fn () => $this->generateSecurePassword())
|
->default(fn () => $this->generateSecurePassword())
|
||||||
->minLength(8)
|
->minLength(8)
|
||||||
->rules([
|
->rules([
|
||||||
@@ -504,7 +554,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
|
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
|
||||||
TextInput::make('admin_email')
|
TextInput::make('admin_email')
|
||||||
->label(__('Admin Email'))
|
->label(__('Admin Email'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->email()
|
->email()
|
||||||
->default(Auth::user()->email ?? '')
|
->default(Auth::user()->email ?? '')
|
||||||
->helperText(__('Email address for the WordPress admin account')),
|
->helperText(__('Email address for the WordPress admin account')),
|
||||||
@@ -538,14 +589,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
])
|
])
|
||||||
->default('en_US')
|
->default('en_US')
|
||||||
->searchable()
|
->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')),
|
->helperText(__('Default language for WordPress admin and content')),
|
||||||
Toggle::make('enable_cache')
|
Toggle::make('enable_cache')
|
||||||
->label(__('Enable Jabali Cache'))
|
->label(__('Enable Jabali Cache'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->helperText(__('Install Redis object caching for better performance'))
|
->helperText(__('Install Redis object caching for better performance'))
|
||||||
->default(true),
|
->default(true),
|
||||||
Toggle::make('enable_auto_update')
|
Toggle::make('enable_auto_update')
|
||||||
->label(__('Enable Auto-Updates'))
|
->label(__('Enable Auto-Updates'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->helperText(__('Automatically update WordPress, plugins, and themes'))
|
->helperText(__('Automatically update WordPress, plugins, and themes'))
|
||||||
->default(false),
|
->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 {
|
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()
|
Notification::make()
|
||||||
->title(__('Creating Staging Environment...'))
|
->title(__('Creating Staging Environment...'))
|
||||||
->body(__('This may take several minutes.'))
|
->body(__('This may take several minutes.'))
|
||||||
->info()
|
->info()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$result = $this->getAgent()->send('wp.create_staging', [
|
$result = $this->getAgent()->send('wp.create_staging', $agentPayload);
|
||||||
'username' => $this->getUsername(),
|
|
||||||
'site_id' => $siteId,
|
|
||||||
'subdomain' => 'staging-'.$subdomain,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
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()
|
Notification::make()
|
||||||
->title(__('Staging Environment Created'))
|
->title(__('Staging Environment Created'))
|
||||||
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
|
->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));
|
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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class DnsPendingAddsTable extends Component implements HasActions, HasSchemas, H
|
|||||||
->label(__('TTL')),
|
->label(__('TTL')),
|
||||||
TextColumn::make('priority')
|
TextColumn::make('priority')
|
||||||
->label(__('Priority'))
|
->label(__('Priority'))
|
||||||
->placeholder('-'),
|
->placeholder(__('-')),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('removePending')
|
Action::make('removePending')
|
||||||
|
|||||||
@@ -11,9 +11,17 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class GitWebhookController extends Controller
|
class GitWebhookController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request, GitDeployment $deployment, string $token): JsonResponse
|
public function __invoke(Request $request, GitDeployment $deployment, ?string $token = null): JsonResponse
|
||||||
{
|
{
|
||||||
if (! hash_equals($deployment->secret_token, $token)) {
|
$payload = $request->getContent();
|
||||||
|
$providedSignature = (string) ($request->header('X-Jabali-Signature') ?? $request->header('X-Hub-Signature-256') ?? '');
|
||||||
|
$providedSignature = preg_replace('/^sha256=/i', '', trim($providedSignature)) ?: '';
|
||||||
|
$expectedSignature = hash_hmac('sha256', $payload, $deployment->secret_token);
|
||||||
|
|
||||||
|
$hasValidSignature = $providedSignature !== '' && hash_equals($expectedSignature, $providedSignature);
|
||||||
|
$hasValidLegacyToken = $token !== null && hash_equals($deployment->secret_token, $token);
|
||||||
|
|
||||||
|
if (! $hasValidSignature && ! $hasValidLegacyToken) {
|
||||||
return response()->json(['message' => 'Invalid token'], 403);
|
return response()->json(['message' => 'Invalid token'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,4 +232,6 @@ class BackupSchedule extends Model
|
|||||||
default => 'gray',
|
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());
|
\Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +100,7 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
$masterUser = $user->username.'_admin';
|
$masterUser = $user->username.'_admin';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (class_exists(\mysqli::class)) {
|
||||||
// Use credentials from environment variables
|
// Use credentials from environment variables
|
||||||
$mysqli = new \mysqli(
|
$mysqli = new \mysqli(
|
||||||
config('database.connections.mysql.host', 'localhost'),
|
config('database.connections.mysql.host', 'localhost'),
|
||||||
@@ -120,12 +121,16 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
|
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
|
||||||
$mysqli->close();
|
$mysqli->close();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Delete stored credentials
|
} catch (\Throwable $e) {
|
||||||
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
\Log::error('Failed to delete master MySQL user: '.$e->getMessage());
|
\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());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,30 +164,27 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
*/
|
*/
|
||||||
public function getDiskUsageBytes(): int
|
public function getDiskUsageBytes(): int
|
||||||
{
|
{
|
||||||
// Try to get usage from quota system first (more accurate)
|
// Disk usage must be obtained via the agent (root) to avoid permission-based undercounting.
|
||||||
try {
|
try {
|
||||||
$agent = new \App\Services\Agent\AgentClient;
|
$agent = new \App\Services\Agent\AgentClient(
|
||||||
$result = $agent->quotaGet($this->username, '/');
|
(string) config('jabali.agent.socket', '/var/run/jabali/agent.sock'),
|
||||||
|
(int) config('jabali.agent.timeout', 120),
|
||||||
|
);
|
||||||
|
|
||||||
|
$mount = $this->home_directory ?: ("/home/{$this->username}");
|
||||||
|
$result = $agent->quotaGet($this->username, $mount);
|
||||||
|
|
||||||
if (($result['success'] ?? false) && isset($result['used_mb'])) {
|
if (($result['success'] ?? false) && isset($result['used_mb'])) {
|
||||||
return (int) ($result['used_mb'] * 1024 * 1024);
|
return (int) ($result['used_mb'] * 1024 * 1024);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
// Fall back to du command
|
\Log::warning('Disk usage read failed via agent: '.$e->getMessage(), [
|
||||||
|
'username' => $this->username,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: try du command (may not work if www-data can't read home dir)
|
|
||||||
$homeDir = $this->home_directory;
|
|
||||||
|
|
||||||
if (! is_dir($homeDir)) {
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = shell_exec('du -sb '.escapeshellarg($homeDir).' 2>/dev/null | cut -f1');
|
|
||||||
|
|
||||||
return (int) trim($output ?: '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get formatted disk usage string.
|
* Get formatted disk usage string.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ namespace App\Providers;
|
|||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
use App\Observers\DomainObserver;
|
use App\Observers\DomainObserver;
|
||||||
use Filament\Support\Facades\FilamentAsset;
|
use Filament\Support\Facades\FilamentAsset;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Register any application services.
|
* Register any application services.
|
||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap any application services.
|
* Bootstrap any application services.
|
||||||
@@ -24,6 +25,31 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
Domain::observe(DomainObserver::class);
|
Domain::observe(DomainObserver::class);
|
||||||
|
|
||||||
|
RateLimiter::for('api', function (Request $request): array {
|
||||||
|
$identifier = $request->user()?->getAuthIdentifier() ?? $request->ip();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Limit::perMinute(120)->by('api:'.$identifier),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('internal-api', function (Request $request): array {
|
||||||
|
$remoteAddr = (string) $request->server('REMOTE_ADDR', $request->ip());
|
||||||
|
|
||||||
|
return [
|
||||||
|
Limit::perMinute(60)->by('internal:'.$remoteAddr),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('git-webhooks', function (Request $request): array {
|
||||||
|
$deploymentId = $request->route('deployment');
|
||||||
|
$deploymentKey = is_object($deploymentId) ? (string) $deploymentId->getKey() : (string) $deploymentId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Limit::perMinute(120)->by('webhook:'.$deploymentKey.':'.$request->ip()),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
$versionFile = base_path('VERSION');
|
$versionFile = base_path('VERSION');
|
||||||
$appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null;
|
$appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null;
|
||||||
FilamentAsset::appVersion($appVersion ?: null);
|
FilamentAsset::appVersion($appVersion ?: null);
|
||||||
|
|||||||
@@ -504,13 +504,19 @@ class AgentClient
|
|||||||
return $this->send('wp.import', $params);
|
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,
|
'username' => $username,
|
||||||
'site_id' => $siteId,
|
'site_id' => $siteId,
|
||||||
'subdomain' => $subdomain,
|
'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
|
public function wpPushStaging(string $username, string $stagingSiteId): array
|
||||||
|
|||||||
890
bin/jabali-agent
@@ -3,6 +3,7 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -12,6 +13,24 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$trustedProxies = env('TRUSTED_PROXIES');
|
||||||
|
$resolvedProxies = match (true) {
|
||||||
|
is_string($trustedProxies) && trim($trustedProxies) === '*' => '*',
|
||||||
|
is_string($trustedProxies) && trim($trustedProxies) !== '' => array_values(array_filter(array_map(
|
||||||
|
static fn (string $proxy): string => trim($proxy),
|
||||||
|
explode(',', $trustedProxies)
|
||||||
|
))),
|
||||||
|
default => ['127.0.0.1', '::1'],
|
||||||
|
};
|
||||||
|
|
||||||
|
$middleware->trustProxies(
|
||||||
|
at: $resolvedProxies,
|
||||||
|
headers: Request::HEADER_X_FORWARDED_FOR
|
||||||
|
| Request::HEADER_X_FORWARDED_HOST
|
||||||
|
| Request::HEADER_X_FORWARDED_PORT
|
||||||
|
| Request::HEADER_X_FORWARDED_PROTO
|
||||||
|
);
|
||||||
|
$middleware->throttleApi('api');
|
||||||
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
|
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"filament/blueprint": "^2.0",
|
"filament/blueprint": "^2.1",
|
||||||
"laravel/boost": "*",
|
"laravel/boost": "*",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
|
|||||||
127
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "194d87cc129a30c6e832109fb820097a",
|
"content-hash": "7083b0b087c4b503b50d3aa23cfbbfac",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -5286,16 +5286,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psy/psysh",
|
"name": "psy/psysh",
|
||||||
"version": "v0.12.18",
|
"version": "v0.12.20",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/bobthecow/psysh.git",
|
"url": "https://github.com/bobthecow/psysh.git",
|
||||||
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196"
|
"reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196",
|
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373",
|
||||||
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196",
|
"reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -5359,9 +5359,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/bobthecow/psysh/issues",
|
"issues": "https://github.com/bobthecow/psysh/issues",
|
||||||
"source": "https://github.com/bobthecow/psysh/tree/v0.12.18"
|
"source": "https://github.com/bobthecow/psysh/tree/v0.12.20"
|
||||||
},
|
},
|
||||||
"time": "2025-12-17T14:35:46+00:00"
|
"time": "2026-02-11T15:05:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ralouphie/getallheaders",
|
"name": "ralouphie/getallheaders",
|
||||||
@@ -5981,16 +5981,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/console",
|
"name": "symfony/console",
|
||||||
"version": "v7.4.3",
|
"version": "v7.4.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/console.git",
|
"url": "https://github.com/symfony/console.git",
|
||||||
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6"
|
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
|
"url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
|
||||||
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
|
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -6055,7 +6055,7 @@
|
|||||||
"terminal"
|
"terminal"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/console/tree/v7.4.3"
|
"source": "https://github.com/symfony/console/tree/v7.4.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6075,7 +6075,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-23T14:50:43+00:00"
|
"time": "2026-01-13T11:36:38+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/css-selector",
|
"name": "symfony/css-selector",
|
||||||
@@ -7803,16 +7803,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/process",
|
"name": "symfony/process",
|
||||||
"version": "v7.4.3",
|
"version": "v7.4.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/process.git",
|
"url": "https://github.com/symfony/process.git",
|
||||||
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f"
|
"reference": "608476f4604102976d687c483ac63a79ba18cc97"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
|
"url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
|
||||||
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
|
"reference": "608476f4604102976d687c483ac63a79ba18cc97",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -7844,7 +7844,7 @@
|
|||||||
"description": "Executes commands in sub-processes",
|
"description": "Executes commands in sub-processes",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/process/tree/v7.4.3"
|
"source": "https://github.com/symfony/process/tree/v7.4.5"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -7864,7 +7864,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-19T10:00:43+00:00"
|
"time": "2026-01-26T15:07:59+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/routing",
|
"name": "symfony/routing",
|
||||||
@@ -8040,16 +8040,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/string",
|
"name": "symfony/string",
|
||||||
"version": "v8.0.1",
|
"version": "v8.0.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/string.git",
|
"url": "https://github.com/symfony/string.git",
|
||||||
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc"
|
"reference": "758b372d6882506821ed666032e43020c4f57194"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc",
|
"url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194",
|
||||||
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc",
|
"reference": "758b372d6882506821ed666032e43020c4f57194",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -8106,7 +8106,7 @@
|
|||||||
"utf8"
|
"utf8"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/string/tree/v8.0.1"
|
"source": "https://github.com/symfony/string/tree/v8.0.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8126,7 +8126,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-01T09:13:36+00:00"
|
"time": "2026-01-12T12:37:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/translation",
|
"name": "symfony/translation",
|
||||||
@@ -8383,16 +8383,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/var-dumper",
|
"name": "symfony/var-dumper",
|
||||||
"version": "v7.4.3",
|
"version": "v7.4.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/var-dumper.git",
|
"url": "https://github.com/symfony/var-dumper.git",
|
||||||
"reference": "7e99bebcb3f90d8721890f2963463280848cba92"
|
"reference": "0e4769b46a0c3c62390d124635ce59f66874b282"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92",
|
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282",
|
||||||
"reference": "7e99bebcb3f90d8721890f2963463280848cba92",
|
"reference": "0e4769b46a0c3c62390d124635ce59f66874b282",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -8446,7 +8446,7 @@
|
|||||||
"dump"
|
"dump"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/var-dumper/tree/v7.4.3"
|
"source": "https://github.com/symfony/var-dumper/tree/v7.4.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8466,7 +8466,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-18T07:04:31+00:00"
|
"time": "2026-01-01T22:13:48+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
@@ -8817,14 +8817,14 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/blueprint",
|
"name": "filament/blueprint",
|
||||||
"version": "v2.0.1",
|
"version": "v2.1.0",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://packages.filamentphp.com/composer/10/127/download"
|
"url": "https://packages.filamentphp.com/composer/10/473/download"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"filament/support": "^5.0",
|
"filament/support": "^5.0",
|
||||||
"laravel/boost": "^1.8"
|
"laravel/boost": "^1.8|^2.0"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": [
|
"license": [
|
||||||
@@ -9820,28 +9820,28 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-file-iterator",
|
"name": "phpunit/php-file-iterator",
|
||||||
"version": "5.1.0",
|
"version": "5.1.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
||||||
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6"
|
"reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6",
|
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903",
|
||||||
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6",
|
"reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2"
|
"php": ">=8.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0"
|
"phpunit/phpunit": "^11.3"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "5.0-dev"
|
"dev-main": "5.1-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -9869,15 +9869,27 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0"
|
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"url": "https://github.com/sebastianbergmann",
|
"url": "https://github.com/sebastianbergmann",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://liberapay.com/sebastianbergmann",
|
||||||
|
"type": "liberapay"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://thanks.dev/u/gh/sebastianbergmann",
|
||||||
|
"type": "thanks_dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
|
||||||
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-08-27T05:02:59+00:00"
|
"time": "2026-02-02T13:52:54+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-invoker",
|
"name": "phpunit/php-invoker",
|
||||||
@@ -10065,16 +10077,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "11.5.48",
|
"version": "11.5.53",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89"
|
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fe3665c15e37140f55aaf658c81a2eb9030b6d89",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
||||||
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89",
|
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10089,18 +10101,19 @@
|
|||||||
"phar-io/version": "^3.2.1",
|
"phar-io/version": "^3.2.1",
|
||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"phpunit/php-code-coverage": "^11.0.12",
|
"phpunit/php-code-coverage": "^11.0.12",
|
||||||
"phpunit/php-file-iterator": "^5.1.0",
|
"phpunit/php-file-iterator": "^5.1.1",
|
||||||
"phpunit/php-invoker": "^5.0.1",
|
"phpunit/php-invoker": "^5.0.1",
|
||||||
"phpunit/php-text-template": "^4.0.1",
|
"phpunit/php-text-template": "^4.0.1",
|
||||||
"phpunit/php-timer": "^7.0.1",
|
"phpunit/php-timer": "^7.0.1",
|
||||||
"sebastian/cli-parser": "^3.0.2",
|
"sebastian/cli-parser": "^3.0.2",
|
||||||
"sebastian/code-unit": "^3.0.3",
|
"sebastian/code-unit": "^3.0.3",
|
||||||
"sebastian/comparator": "^6.3.2",
|
"sebastian/comparator": "^6.3.3",
|
||||||
"sebastian/diff": "^6.0.2",
|
"sebastian/diff": "^6.0.2",
|
||||||
"sebastian/environment": "^7.2.1",
|
"sebastian/environment": "^7.2.1",
|
||||||
"sebastian/exporter": "^6.3.2",
|
"sebastian/exporter": "^6.3.2",
|
||||||
"sebastian/global-state": "^7.0.2",
|
"sebastian/global-state": "^7.0.2",
|
||||||
"sebastian/object-enumerator": "^6.0.1",
|
"sebastian/object-enumerator": "^6.0.1",
|
||||||
|
"sebastian/recursion-context": "^6.0.3",
|
||||||
"sebastian/type": "^5.1.3",
|
"sebastian/type": "^5.1.3",
|
||||||
"sebastian/version": "^5.0.2",
|
"sebastian/version": "^5.0.2",
|
||||||
"staabm/side-effects-detector": "^1.0.5"
|
"staabm/side-effects-detector": "^1.0.5"
|
||||||
@@ -10146,7 +10159,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.48"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10170,7 +10183,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-01-16T16:26:27+00:00"
|
"time": "2026-02-10T12:28:25+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@@ -10344,16 +10357,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/comparator",
|
"name": "sebastian/comparator",
|
||||||
"version": "6.3.2",
|
"version": "6.3.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||||
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8"
|
"reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8",
|
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
|
||||||
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8",
|
"reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10412,7 +10425,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2"
|
"source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10432,7 +10445,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-08-10T08:07:46+00:00"
|
"time": "2026-01-24T09:26:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/complexity",
|
"name": "sebastian/complexity",
|
||||||
@@ -11346,5 +11359,5 @@
|
|||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
26
config.toml.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Jabali Panel repository config
|
||||||
|
#
|
||||||
|
# Used by `scripts/deploy.sh` (CLI flags still override these settings).
|
||||||
|
# Keep secrets out of this file. Prefer SSH keys and server-side git remotes.
|
||||||
|
|
||||||
|
[deploy]
|
||||||
|
# Test server (where GitHub deploy key is configured)
|
||||||
|
host = "192.168.100.50"
|
||||||
|
user = "root"
|
||||||
|
path = "/var/www/jabali"
|
||||||
|
www_user = "www-data"
|
||||||
|
|
||||||
|
# Optional: keep npm cache outside the repo (saves time on repeated builds)
|
||||||
|
# npm_cache_dir = "/var/www/.npm"
|
||||||
|
|
||||||
|
# Optional: override the branch that gets pushed from the deploy server
|
||||||
|
push_branch = "main"
|
||||||
|
|
||||||
|
# Optional: push to explicit URLs (instead of relying on named remotes)
|
||||||
|
# These pushes run FROM the test server.
|
||||||
|
gitea_url = "ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git"
|
||||||
|
github_url = "git@github.com:shukiv/jabali-panel.git"
|
||||||
|
|
||||||
|
# If you prefer named remotes on the deploy server instead of URLs:
|
||||||
|
# gitea_remote = "gitea"
|
||||||
|
# github_remote = "origin"
|
||||||
@@ -123,4 +123,15 @@ return [
|
|||||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Internal API Token
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Optional shared token for internal endpoints that may be called from
|
||||||
|
| non-localhost environments (for example, when using a reverse proxy).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'internal_api_token' => env('JABALI_INTERNAL_API_TOKEN'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ return [
|
|||||||
'root' => '/tmp',
|
'root' => '/tmp',
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Server-wide backups folder (created by install.sh)
|
||||||
|
'backups' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => env('JABALI_BACKUPS_ROOT', '/var/backups/jabali'),
|
||||||
|
'throw' => false,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'links' => [
|
'links' => [
|
||||||
|
|||||||
4
doccs/site/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.DS_Store
|
||||||
4
doccs/site/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
11
doccs/site/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
49
doccs/site/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Starlight Starter Kit: Basics
|
||||||
|
|
||||||
|
[](https://starlight.astro.build)
|
||||||
|
|
||||||
|
```
|
||||||
|
npm create astro@latest -- --template starlight
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ ├── assets/
|
||||||
|
│ ├── content/
|
||||||
|
│ │ └── docs/
|
||||||
|
│ └── content.config.ts
|
||||||
|
├── astro.config.mjs
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
||||||
|
|
||||||
|
Static assets, like favicons, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||||
36
doccs/site/astro.config.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import starlight from '@astrojs/starlight';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
integrations: [
|
||||||
|
starlight({
|
||||||
|
title: 'Jabali Panel Documentation',
|
||||||
|
description: 'Feature documentation and screenshots for the Jabali hosting panel.',
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
label: 'Getting Started',
|
||||||
|
items: [{ label: 'Overview', slug: 'overview' }, { label: 'Installation', slug: 'install' }, { label: 'Quickstart', slug: 'quickstart' }, { label: 'Backups and Restore', slug: 'backups-restore' }, { label: 'Migrations', slug: 'migrations' }, { label: 'DNS and Mail', slug: 'dns-mail' }, { label: 'Operations', slug: 'operations' }, { label: 'Security', slug: 'security' }, { label: 'Troubleshooting', slug: 'troubleshooting' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Admin Panel',
|
||||||
|
autogenerate: { directory: 'admin' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'User Panel',
|
||||||
|
autogenerate: { directory: 'user' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Platform',
|
||||||
|
autogenerate: { directory: 'platform' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
6394
doccs/site/package-lock.json
generated
Normal file
17
doccs/site/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "doccs-site",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=jabali.lan astro dev",
|
||||||
|
"start": "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=jabali.lan astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/starlight": "^0.37.6",
|
||||||
|
"astro": "^5.6.1",
|
||||||
|
"sharp": "^0.34.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
doccs/site/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>
|
||||||
|
After Width: | Height: | Size: 696 B |
BIN
doccs/site/public/screenshots/admin-automation-api.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
doccs/site/public/screenshots/admin-backup-download.png
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
doccs/site/public/screenshots/admin-backups--tab-backups.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 107 KiB |
BIN
doccs/site/public/screenshots/admin-backups--tab-schedules.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
doccs/site/public/screenshots/admin-backups.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
doccs/site/public/screenshots/admin-cpanel-migration.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
doccs/site/public/screenshots/admin-dashboard.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
doccs/site/public/screenshots/admin-database-tuning.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
doccs/site/public/screenshots/admin-dns-zones.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
doccs/site/public/screenshots/admin-email-logs.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
doccs/site/public/screenshots/admin-email-queue.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
doccs/site/public/screenshots/admin-geo-block-rules-5-edit.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
doccs/site/public/screenshots/admin-geo-block-rules-create.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
doccs/site/public/screenshots/admin-geo-block-rules-edit.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
doccs/site/public/screenshots/admin-geo-block-rules.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
doccs/site/public/screenshots/admin-home.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
doccs/site/public/screenshots/admin-hosting-packages-1-edit.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
doccs/site/public/screenshots/admin-hosting-packages-create.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
doccs/site/public/screenshots/admin-hosting-packages-edit.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
doccs/site/public/screenshots/admin-hosting-packages.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
doccs/site/public/screenshots/admin-ip-addresses.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
doccs/site/public/screenshots/admin-login.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 124 KiB |
BIN
doccs/site/public/screenshots/admin-migration.png
Normal file
|
After Width: | Height: | Size: 118 KiB |