Files
jabali-panel/app/Filament/Admin/Pages/ServerSettings.php
2026-01-28 00:50:06 +02:00

1253 lines
53 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Filament\Admin\Widgets\Settings\DnssecTable;
use App\Filament\Admin\Widgets\Settings\NotificationLogTable;
use App\Filament\Concerns\HasPageTour;
use App\Models\DnsSetting;
use App\Models\UserResourceLimit;
use App\Services\Agent\AgentClient;
use App\Services\System\ResourceLimitService;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Action as FormAction;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Url;
use Livewire\WithFileUploads;
class ServerSettings extends Page implements HasActions, HasForms
{
use HasPageTour;
use InteractsWithActions;
use InteractsWithForms;
use WithFileUploads;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static ?int $navigationSort = 3;
protected static ?string $slug = 'server-settings';
public static function getNavigationLabel(): string
{
return __('Server Settings');
}
protected string $view = 'filament.admin.pages.server-settings';
// Form data arrays
public ?array $brandingData = [];
public ?array $hostnameData = [];
public ?array $dnsData = [];
public ?array $resolversData = [];
public ?array $quotaData = [];
public ?array $fileManagerData = [];
public ?array $emailData = [];
public ?array $notificationsData = [];
public ?array $phpFpmData = [];
// Version info (non-form)
public string $currentVersion = '';
public string $latestVersion = '';
public int $updatesAvailable = 0;
public bool $isChecking = false;
public bool $isUpgrading = false;
public string $upgradeLog = '';
public bool $isSystemdResolved = false;
public ?string $currentLogo = null;
#[Url(as: 'tab')]
public ?string $activeTab = 'general';
public function getLogoUrlProperty(): ?string
{
if ($this->currentLogo && Storage::disk('public')->exists($this->currentLogo)) {
return asset('storage/'.$this->currentLogo);
}
return null;
}
protected function getAgent(): AgentClient
{
return new AgentClient;
}
public function getTitle(): string|Htmlable
{
return __('Server Settings');
}
protected function normalizeTabName(?string $tab): string
{
return match ($tab) {
'general', 'dns', 'storage', 'email', 'notifications', 'php-fpm' => $tab,
default => 'general',
};
}
public function setTab(string $tab): void
{
$this->activeTab = $this->normalizeTabName($tab);
}
public function mount(): void
{
$this->activeTab = $this->normalizeTabName($this->activeTab);
$settings = DnsSetting::getAll();
$hostname = gethostname() ?: 'localhost';
$serverIp = trim(shell_exec("hostname -I | awk '{print $1}'") ?? '') ?: '';
$this->currentLogo = $settings['custom_logo'] ?? null;
$this->isSystemdResolved = trim(shell_exec('systemctl is-active systemd-resolved 2>/dev/null') ?? '') === 'active';
// Load hostname from agent
$agentHostname = $hostname;
try {
$result = $this->getAgent()->send('server.info', []);
if ($result['success'] ?? false) {
$agentHostname = $result['info']['hostname'] ?? $hostname;
}
} catch (Exception $e) {
// Use default
}
// Load resolvers
$resolvers = ['', '', ''];
$searchDomain = '';
if (file_exists('/etc/resolv.conf')) {
$content = file_get_contents('/etc/resolv.conf');
$lines = explode("\n", $content);
$ns = [];
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, 'nameserver ')) {
$ns[] = trim(substr($line, 11));
} elseif (str_starts_with($line, 'search ')) {
$searchDomain = trim(substr($line, 7));
}
}
$resolvers = [$ns[0] ?? '', $ns[1] ?? '', $ns[2] ?? ''];
}
// Fill form data
$this->brandingData = [
'panel_name' => $settings['panel_name'] ?? 'Jabali',
];
$this->hostnameData = [
'hostname' => $agentHostname,
];
$this->dnsData = [
'ns1' => $settings['ns1'] ?? "ns1.{$hostname}",
'ns1_ip' => $settings['ns1_ip'] ?? $serverIp,
'ns2' => $settings['ns2'] ?? "ns2.{$hostname}",
'ns2_ip' => $settings['ns2_ip'] ?? $serverIp,
'default_ip' => $settings['default_ip'] ?? $serverIp,
'default_ipv6' => $settings['default_ipv6'] ?? '',
'default_ttl' => $settings['default_ttl'] ?? '3600',
'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}",
];
$this->resolversData = [
'resolver1' => $resolvers[0],
'resolver2' => $resolvers[1],
'resolver3' => $resolvers[2],
'search_domain' => $searchDomain,
];
$this->quotaData = [
'quotas_enabled' => (bool) ($settings['quotas_enabled'] ?? false),
'default_quota_mb' => (int) ($settings['default_quota_mb'] ?? 5120),
'resource_limits_enabled' => (bool) ($settings['resource_limits_enabled'] ?? true),
];
$this->fileManagerData = [
'max_upload_size_mb' => (int) ($settings['max_upload_size_mb'] ?? 100),
];
$this->emailData = [
'mail_hostname' => $settings['mail_hostname'] ?? "mail.{$hostname}",
'mail_default_quota_mb' => (int) ($settings['mail_default_quota_mb'] ?? 1024),
'max_mailboxes_per_domain' => (int) ($settings['max_mailboxes_per_domain'] ?? 10),
'webmail_url' => $settings['webmail_url'] ?? '/webmail',
'webmail_product_name' => $settings['webmail_product_name'] ?? 'Jabali Webmail',
];
$this->notificationsData = [
'admin_email_recipients' => $settings['admin_email_recipients'] ?? '',
'notify_ssl_errors' => (bool) ($settings['notify_ssl_errors'] ?? true),
'notify_backup_failures' => (bool) ($settings['notify_backup_failures'] ?? true),
'notify_backup_success' => (bool) ($settings['notify_backup_success'] ?? false),
'notify_disk_quota' => (bool) ($settings['notify_disk_quota'] ?? true),
'notify_login_failures' => (bool) ($settings['notify_login_failures'] ?? true),
'notify_ssh_logins' => (bool) ($settings['notify_ssh_logins'] ?? false),
'notify_system_updates' => (bool) ($settings['notify_system_updates'] ?? false),
'notify_service_health' => (bool) ($settings['notify_service_health'] ?? true),
'notify_high_load' => (bool) ($settings['notify_high_load'] ?? true),
'load_threshold' => (float) ($settings['load_threshold'] ?? 5.0),
'load_alert_minutes' => (int) ($settings['load_alert_minutes'] ?? 5),
];
$this->phpFpmData = [
'pm_max_children' => (int) ($settings['fpm_pm_max_children'] ?? 5),
'pm_max_requests' => (int) ($settings['fpm_pm_max_requests'] ?? 200),
'rlimit_files' => (int) ($settings['fpm_rlimit_files'] ?? 1024),
'process_priority' => (int) ($settings['fpm_process_priority'] ?? 0),
'request_terminate_timeout' => (int) ($settings['fpm_request_terminate_timeout'] ?? 300),
'memory_limit' => $settings['fpm_memory_limit'] ?? '512M',
];
$this->loadVersionInfo();
}
public function settingsForm(Schema $schema): Schema
{
return $schema
->schema([
View::make('filament.admin.components.server-settings-tabs-nav'),
...$this->getTabContent(),
]);
}
protected function getTabContent(): array
{
return match ($this->activeTab) {
'general' => $this->generalTabContent(),
'dns' => $this->dnsTabContent(),
'storage' => $this->storageTabContent(),
'email' => $this->emailTabContent(),
'notifications' => $this->notificationsTabContent(),
'php-fpm' => $this->phpFpmTabContent(),
default => $this->generalTabContent(),
};
}
protected function generalTabContent(): array
{
return [
Section::make(__('Panel Version & Updates'))
->description($this->currentVersion ?: __('Unknown'))
->icon('heroicon-o-arrow-up-tray')
->schema([
Actions::make([
FormAction::make('checkForUpdates')
->label(__('Check for Updates'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action('checkForUpdates'),
FormAction::make('performUpgrade')
->label(__('Upgrade Now'))
->icon('heroicon-o-arrow-up-tray')
->color('success')
->requiresConfirmation()
->action('performUpgrade')
->visible(fn () => $this->updatesAvailable > 0),
]),
]),
Section::make(__('Panel Branding'))
->icon('heroicon-o-paint-brush')
->schema([
Grid::make(['default' => 1, 'md' => 2])->schema([
TextInput::make('brandingData.panel_name')
->label(__('Control Panel Name'))
->placeholder('Jabali')
->helperText(__('Appears in browser title and navigation'))
->required(),
]),
Actions::make([
FormAction::make('uploadLogo')
->label(__('Upload Logo'))
->icon('heroicon-o-arrow-up-tray')
->color('gray')
->form([
FileUpload::make('logo')
->label(__('Logo Image'))
->image()
->disk('public')
->directory('branding')
->visibility('public')
->acceptedFileTypes(['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp'])
->maxSize(1024)
->required()
->helperText(__('SVG, PNG, JPEG or WebP, max 1MB')),
])
->action(function (array $data): void {
$this->uploadLogo($data);
}),
FormAction::make('removeLogo')
->label(__('Remove Logo'))
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->action(fn () => $this->removeLogo())
->visible(fn () => $this->currentLogo !== null),
FormAction::make('saveBranding')
->label(__('Save Branding'))
->action('saveBranding'),
]),
]),
Section::make(__('Server Hostname'))
->icon('heroicon-o-server')
->schema([
TextInput::make('hostnameData.hostname')
->label(__('Hostname'))
->placeholder('server.example.com')
->required(),
Actions::make([
FormAction::make('saveHostname')
->label(__('Save Hostname'))
->action('saveHostname'),
]),
]),
];
}
protected function dnsTabContent(): array
{
return [
Section::make(__('Nameservers'))
->icon('heroicon-o-server-stack')
->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_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_ip')->label(__('NS2 IP Address'))->placeholder('192.168.1.2'),
]),
]),
Section::make(__('Zone Defaults'))
->schema([
Grid::make(['default' => 1, 'md' => 3])->schema([
TextInput::make('dnsData.default_ip')
->label(__('Default Server IP'))
->placeholder('192.168.1.1')
->helperText(__('Default A record IP for new zones')),
TextInput::make('dnsData.default_ipv6')
->label(__('Default IPv6'))
->placeholder('2001:db8::1')
->helperText(__('Default AAAA record IP for new zones'))
->rule('nullable|ipv6'),
TextInput::make('dnsData.default_ttl')
->label(__('Default TTL'))
->placeholder('3600'),
]),
TextInput::make('dnsData.admin_email')
->label(__('Admin Email (SOA)'))
->placeholder('admin.example.com')
->helperText(__('Use dots instead of @ (e.g., admin.example.com)')),
Actions::make([
FormAction::make('saveDns')
->label(__('Save DNS Settings'))
->action('saveDns'),
]),
]),
Section::make(__('DNS Resolvers'))
->description($this->isSystemdResolved ? __('systemd-resolved active') : null)
->icon('heroicon-o-signal')
->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.resolver2')->label(__('Resolver 2'))->placeholder('8.8.4.4'),
TextInput::make('resolversData.resolver3')->label(__('Resolver 3'))->placeholder('1.1.1.1'),
TextInput::make('resolversData.search_domain')->label(__('Search Domain'))->placeholder('example.com'),
]),
Actions::make([
FormAction::make('saveResolvers')
->label(__('Save Resolvers'))
->action('saveResolvers'),
]),
]),
Section::make(__('DNSSEC'))
->description(__('DNS Security Extensions'))
->icon('heroicon-o-shield-check')
->schema([
EmbeddedTable::make(DnssecTable::class),
]),
];
}
protected function storageTabContent(): array
{
return [
Section::make(__('Disk Quotas'))
->icon('heroicon-o-chart-pie')
->schema([
Grid::make(['default' => 1, 'md' => 2])->schema([
Toggle::make('quotaData.quotas_enabled')
->label(__('Enable Disk Quotas'))
->helperText(__('When enabled, disk usage limits will be enforced for user accounts')),
TextInput::make('quotaData.default_quota_mb')
->label(__('Default Quota (MB)'))
->numeric()
->placeholder('5120')
->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')),
Toggle::make('quotaData.resource_limits_enabled')
->label(__('Enable CPU/Memory/IO Limits'))
->helperText(__('Apply cgroup limits from hosting packages (CloudLinux-style)'))
->columnSpanFull(),
]),
Actions::make([
FormAction::make('saveQuotaSettings')
->label(__('Save Quota Settings'))
->action('saveQuotaSettings'),
]),
]),
Section::make(__('File Manager'))
->icon('heroicon-o-folder')
->schema([
TextInput::make('fileManagerData.max_upload_size_mb')
->label(__('Max Upload Size (MB)'))
->numeric()
->minValue(1)
->maxValue(500)
->placeholder('100')
->helperText(__('Maximum file size users can upload (1-500 MB)')),
Actions::make([
FormAction::make('saveFileManagerSettings')
->label(__('Save'))
->action('saveFileManagerSettings'),
]),
]),
];
}
protected function emailTabContent(): array
{
return [
Section::make(__('Mail Server'))
->icon('heroicon-o-envelope')
->schema([
Grid::make(['default' => 1, 'md' => 2])->schema([
TextInput::make('emailData.mail_hostname')
->label(__('Mail Server Hostname'))
->placeholder('mail.example.com')
->helperText(__('The hostname used for mail server identification')),
TextInput::make('emailData.mail_default_quota_mb')
->label(__('Default Mailbox Quota (MB)'))
->numeric()
->minValue(100)
->maxValue(10240),
TextInput::make('emailData.max_mailboxes_per_domain')
->label(__('Max Mailboxes Per Domain'))
->numeric()
->minValue(1)
->maxValue(1000),
]),
]),
Section::make(__('Webmail'))
->icon('heroicon-o-globe-alt')
->schema([
Grid::make(['default' => 1, 'md' => 2])->schema([
TextInput::make('emailData.webmail_url')
->label(__('Webmail URL'))
->placeholder('/webmail')
->helperText(__('URL path for Roundcube webmail')),
TextInput::make('emailData.webmail_product_name')
->label(__('Webmail Product Name'))
->placeholder('Jabali Webmail')
->helperText(__('Name displayed on the webmail login page')),
]),
Actions::make([
FormAction::make('openWebmail')
->label(__('Open Webmail'))
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url('/webmail', shouldOpenInNewTab: true),
FormAction::make('saveEmailSettings')
->label(__('Save Email Settings'))
->action('saveEmailSettings'),
]),
]),
];
}
protected function notificationsTabContent(): array
{
return [
Section::make(__('Admin Recipients'))
->icon('heroicon-o-user-group')
->schema([
TextInput::make('notificationsData.admin_email_recipients')
->label(__('Email Addresses'))
->placeholder('admin@example.com, alerts@example.com')
->helperText(__('Comma-separated list of email addresses to receive notifications')),
]),
Section::make(__('Notification Types'))
->icon('heroicon-o-bell-alert')
->schema([
Grid::make(['default' => 1, 'md' => 2])->schema([
Toggle::make('notificationsData.notify_ssl_errors')
->label(__('SSL Certificate Alerts'))
->helperText(__('Errors and expiring certificates')),
Toggle::make('notificationsData.notify_backup_failures')
->label(__('Backup Failures'))
->helperText(__('Failed scheduled backups')),
Toggle::make('notificationsData.notify_backup_success')
->label(__('Backup Success'))
->helperText(__('Successful backup completions')),
Toggle::make('notificationsData.notify_disk_quota')
->label(__('Disk Quota Warnings'))
->helperText(__('When users reach 90% quota')),
Toggle::make('notificationsData.notify_login_failures')
->label(__('Login Failure Alerts'))
->helperText(__('Brute force and Fail2ban alerts')),
Toggle::make('notificationsData.notify_ssh_logins')
->label(__('SSH Login Alerts'))
->helperText(__('Successful SSH login notifications')),
Toggle::make('notificationsData.notify_system_updates')
->label(__('System Updates Available'))
->helperText(__('When panel updates are available')),
Toggle::make('notificationsData.notify_service_health')
->label(__('Service Health Alerts'))
->helperText(__('Service failures and auto-restarts')),
]),
]),
Section::make(__('High Load Alerts'))
->icon('heroicon-o-cpu-chip')
->schema([
Grid::make(['default' => 1, 'md' => 3])->schema([
Toggle::make('notificationsData.notify_high_load')
->label(__('Enable High Load Alerts'))
->helperText(__('Alert when server load is high')),
TextInput::make('notificationsData.load_threshold')
->label(__('Load Threshold'))
->numeric()
->minValue(1)
->maxValue(100)
->step(0.5)
->placeholder('5')
->helperText(__('Alert when load exceeds this value')),
TextInput::make('notificationsData.load_alert_minutes')
->label(__('Alert After (minutes)'))
->numeric()
->minValue(1)
->maxValue(60)
->placeholder('5')
->helperText(__('Minutes of high load before alerting')),
]),
Actions::make([
FormAction::make('sendTestEmail')
->label(__('Send Test Email'))
->color('gray')
->action('sendTestEmail'),
FormAction::make('saveEmailNotificationSettings')
->label(__('Save Notification Settings'))
->action('saveEmailNotificationSettings'),
]),
]),
Section::make(__('Notification Log'))
->description(__('Last 30 days'))
->icon('heroicon-o-document-text')
->schema([
EmbeddedTable::make(NotificationLogTable::class),
]),
];
}
protected function phpFpmTabContent(): array
{
return [
Section::make(__('Default Pool Limits'))
->description(__('These settings apply to new user pools. Use "Apply to All" to update existing pools.'))
->icon('heroicon-o-adjustments-horizontal')
->schema([
Grid::make(['default' => 1, 'md' => 2, 'lg' => 3])->schema([
TextInput::make('phpFpmData.pm_max_children')
->label(__('Max Processes'))
->numeric()
->minValue(1)
->maxValue(50)
->helperText(__('Max PHP workers per user (1-50)')),
TextInput::make('phpFpmData.pm_max_requests')
->label(__('Max Requests'))
->numeric()
->minValue(50)
->maxValue(10000)
->helperText(__('Requests before worker recycle')),
TextInput::make('phpFpmData.memory_limit')
->label(__('Memory Limit'))
->placeholder('512M')
->helperText(__('PHP memory_limit (e.g., 512M, 1G)')),
]),
Grid::make(['default' => 1, 'md' => 2, 'lg' => 3])->schema([
TextInput::make('phpFpmData.rlimit_files')
->label(__('Open Files Limit'))
->numeric()
->minValue(256)
->maxValue(65536)
->helperText(__('Max open file descriptors')),
TextInput::make('phpFpmData.process_priority')
->label(__('Process Priority'))
->numeric()
->minValue(-20)
->maxValue(19)
->helperText(__('Nice value (-20 to 19, lower = higher priority)')),
TextInput::make('phpFpmData.request_terminate_timeout')
->label(__('Request Timeout (s)'))
->numeric()
->minValue(30)
->maxValue(3600)
->helperText(__('Kill slow requests after this time')),
]),
Actions::make([
FormAction::make('saveFpmSettings')
->label(__('Save Settings'))
->action('saveFpmSettings'),
FormAction::make('applyFpmToAll')
->label(__('Apply to All Users'))
->color('warning')
->icon('heroicon-o-arrow-path')
->requiresConfirmation()
->modalHeading(__('Apply FPM Settings to All Users'))
->modalDescription(__('This will update all existing PHP-FPM pool configurations with the current settings. PHP-FPM will be reloaded.'))
->action('applyFpmToAll'),
]),
]),
];
}
protected function getForms(): array
{
return [
'settingsForm',
];
}
public function saveBranding(): void
{
$data = $this->brandingData;
if (empty(trim($data['panel_name'] ?? ''))) {
Notification::make()->title(__('Panel name cannot be empty'))->danger()->send();
return;
}
DnsSetting::set('panel_name', trim($data['panel_name']));
DnsSetting::clearCache();
Notification::make()->title(__('Branding updated'))->body(__('Refresh to see changes.'))->success()->send();
}
public function uploadLogo(array $data): void
{
try {
$logo = $data['logo'] ?? null;
if (empty($logo)) {
Notification::make()->title(__('No file selected'))->warning()->send();
return;
}
// Filament FileUpload returns an array of stored file paths
$path = is_array($logo) ? ($logo[0] ?? null) : $logo;
if ($path) {
// Delete old logo if exists
if ($this->currentLogo && Storage::disk('public')->exists($this->currentLogo)) {
Storage::disk('public')->delete($this->currentLogo);
}
DnsSetting::set('custom_logo', $path);
DnsSetting::clearCache();
$this->currentLogo = $path;
Notification::make()->title(__('Logo uploaded'))->body(__('Refresh to see changes.'))->success()->send();
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to upload logo'))->body($e->getMessage())->danger()->send();
}
}
public function removeLogo(): void
{
try {
if ($this->currentLogo && Storage::disk('public')->exists($this->currentLogo)) {
Storage::disk('public')->delete($this->currentLogo);
}
DnsSetting::set('custom_logo', null);
DnsSetting::clearCache();
$this->currentLogo = null;
Notification::make()->title(__('Logo removed'))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Failed to remove logo'))->body($e->getMessage())->danger()->send();
}
}
public function saveHostname(): void
{
$hostname = $this->hostnameData['hostname'] ?? '';
if (empty(trim($hostname))) {
Notification::make()->title(__('Hostname cannot be empty'))->danger()->send();
return;
}
$result = $this->getAgent()->send('server.set_hostname', ['hostname' => $hostname]);
if (! ($result['success'] ?? false)) {
Notification::make()->title(__('Failed to update hostname'))->body($result['error'] ?? __('Unknown error'))->danger()->send();
return;
}
// Restart or reload affected services
$services = ['postfix', 'dovecot', 'nginx', 'php8.3-fpm', 'named'];
$updatedServices = [];
$failedServices = [];
foreach ($services as $service) {
$action = $this->shouldReloadService($service) ? 'reload' : 'restart';
$result = $this->getAgent()->send("service.{$action}", ['service' => $service]);
if ($result['success'] ?? false) {
$updatedServices[] = $service;
} else {
$failedServices[] = $service;
}
}
if (empty($failedServices)) {
Notification::make()
->title(__('Hostname updated'))
->body(__('Affected services have been restarted or reloaded.'))
->success()
->send();
} else {
Notification::make()
->title(__('Hostname updated'))
->body(__('Some services failed to restart or reload: :services. If you experience issues, a server reboot may help.', ['services' => implode(', ', $failedServices)]))
->warning()
->send();
}
}
protected function shouldReloadService(string $service): bool
{
if ($service === 'nginx') {
return true;
}
return preg_match('/^php(\d+\.\d+)?-fpm$/', $service) === 1;
}
public function saveDns(): void
{
$data = $this->dnsData;
DnsSetting::set('ns1', $data['ns1']);
DnsSetting::set('ns1_ip', $data['ns1_ip']);
DnsSetting::set('ns2', $data['ns2']);
DnsSetting::set('ns2_ip', $data['ns2_ip']);
DnsSetting::set('default_ip', $data['default_ip']);
DnsSetting::set('default_ipv6', $data['default_ipv6'] ?: null);
DnsSetting::set('default_ttl', $data['default_ttl']);
DnsSetting::set('admin_email', $data['admin_email']);
DnsSetting::clearCache();
$result = $this->getAgent()->send('server.create_zone', [
'hostname' => $this->hostnameData['hostname'],
'ns1' => $data['ns1'],
'ns1_ip' => $data['ns1_ip'],
'ns2' => $data['ns2'],
'ns2_ip' => $data['ns2_ip'],
'admin_email' => $data['admin_email'],
'server_ip' => $data['default_ip'],
'server_ipv6' => $data['default_ipv6'],
'ttl' => $data['default_ttl'],
]);
if ($result['success'] ?? false) {
Notification::make()->title(__('DNS settings saved'))->success()->send();
} else {
Notification::make()->title(__('Settings saved but zone creation failed'))->body($result['error'] ?? __('Unknown error'))->warning()->send();
}
}
public function saveResolvers(): void
{
$data = $this->resolversData;
try {
$nameservers = array_filter([
$data['resolver1'],
$data['resolver2'],
$data['resolver3'],
], fn ($ns) => ! empty(trim($ns ?? '')));
if (empty($nameservers)) {
Notification::make()->title(__('Failed to update DNS resolvers'))->body(__('At least one nameserver is required'))->danger()->send();
return;
}
$result = $this->getAgent()->send('server.set_resolvers', [
'nameservers' => array_values($nameservers),
'search_domains' => ! empty($data['search_domain']) ? [$data['search_domain']] : [],
]);
if ($result['success'] ?? false) {
Notification::make()->title(__('DNS resolvers updated'))->success()->send();
} else {
Notification::make()->title(__('Failed to update DNS resolvers'))->body($result['error'] ?? __('Unknown error'))->danger()->send();
}
} catch (Exception $e) {
Notification::make()->title(__('Failed to update DNS resolvers'))->body($e->getMessage())->danger()->send();
}
}
public function saveQuotaSettings(): void
{
$data = $this->quotaData;
$wasEnabled = (bool) DnsSetting::get('quotas_enabled', false);
$wasLimitsEnabled = (bool) DnsSetting::get('resource_limits_enabled', true);
DnsSetting::set('quotas_enabled', $data['quotas_enabled'] ? '1' : '0');
DnsSetting::set('default_quota_mb', (string) $data['default_quota_mb']);
DnsSetting::set('resource_limits_enabled', ! empty($data['resource_limits_enabled']) ? '1' : '0');
DnsSetting::clearCache();
if ($data['quotas_enabled'] && ! $wasEnabled) {
try {
$result = $this->getAgent()->send('quota.enable', ['path' => '/home']);
if ($result['success'] ?? false) {
Notification::make()->title(__('Disk quotas enabled'))->body(__('Quota system has been initialized on /home'))->success()->send();
} else {
Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not enable quota system on filesystem.'))->warning()->send();
}
} catch (Exception $e) {
Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not enable quota system.'))->warning()->send();
}
}
if (! empty($data['resource_limits_enabled']) && ! $wasLimitsEnabled) {
$limits = UserResourceLimit::query()->where('is_active', true)->get();
foreach ($limits as $limit) {
app(ResourceLimitService::class)->apply($limit);
}
}
if (empty($data['resource_limits_enabled']) && $wasLimitsEnabled) {
try {
$this->getAgent()->send('cgroup.clear_all_limits', []);
} catch (Exception $e) {
Notification::make()->title(__('Settings saved'))->body(__('Warning: Could not clear cgroup limits.'))->warning()->send();
}
}
Notification::make()->title(__('Quota settings saved'))->success()->send();
}
public function saveFileManagerSettings(): void
{
$data = $this->fileManagerData;
$size = max(1, min(500, (int) $data['max_upload_size_mb']));
DnsSetting::set('max_upload_size_mb', (string) $size);
DnsSetting::clearCache();
try {
$result = $this->getAgent()->send('server.set_upload_limits', ['size_mb' => $size]);
if ($result['success'] ?? false) {
Notification::make()->title(__('File manager settings saved'))->body(__('Server upload limits updated to :size MB', ['size' => $size]))->success()->send();
} else {
Notification::make()->title(__('Settings saved'))->body(__('Database updated but server config update had issues'))->warning()->send();
}
} catch (Exception $e) {
Notification::make()->title(__('Settings saved'))->body(__('Database updated but could not update server configs'))->warning()->send();
}
}
public function saveEmailSettings(): void
{
$data = $this->emailData;
DnsSetting::set('mail_hostname', $data['mail_hostname']);
DnsSetting::set('mail_default_quota_mb', (string) $data['mail_default_quota_mb']);
DnsSetting::set('max_mailboxes_per_domain', (string) $data['max_mailboxes_per_domain']);
DnsSetting::set('webmail_url', $data['webmail_url']);
DnsSetting::set('webmail_product_name', $data['webmail_product_name']);
DnsSetting::clearCache();
// Update Roundcube config
$configFile = '/etc/roundcube/config.inc.php';
if (file_exists($configFile)) {
try {
$content = file_get_contents($configFile);
$content = preg_replace(
"/\\\$config\['product_name'\]\s*=\s*'[^']*';/",
"\$config['product_name'] = '".addslashes($data['webmail_product_name'])."';",
$content
);
file_put_contents($configFile, $content);
} catch (Exception $e) {
// Silently fail
}
}
Notification::make()->title(__('Email settings saved'))->success()->send();
}
public function saveEmailNotificationSettings(): void
{
$data = $this->notificationsData;
if (! empty($data['admin_email_recipients'])) {
$emails = array_map('trim', explode(',', $data['admin_email_recipients']));
foreach ($emails as $email) {
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
Notification::make()->title(__('Invalid recipient email'))->body(__(':email is not a valid email address', ['email' => $email]))->danger()->send();
return;
}
}
}
DnsSetting::set('admin_email_recipients', $data['admin_email_recipients']);
DnsSetting::set('notify_ssl_errors', $data['notify_ssl_errors'] ? '1' : '0');
DnsSetting::set('notify_backup_failures', $data['notify_backup_failures'] ? '1' : '0');
DnsSetting::set('notify_backup_success', $data['notify_backup_success'] ? '1' : '0');
DnsSetting::set('notify_disk_quota', $data['notify_disk_quota'] ? '1' : '0');
DnsSetting::set('notify_login_failures', $data['notify_login_failures'] ? '1' : '0');
DnsSetting::set('notify_ssh_logins', $data['notify_ssh_logins'] ? '1' : '0');
DnsSetting::set('notify_system_updates', $data['notify_system_updates'] ? '1' : '0');
DnsSetting::set('notify_service_health', $data['notify_service_health'] ? '1' : '0');
DnsSetting::set('notify_high_load', $data['notify_high_load'] ? '1' : '0');
DnsSetting::set('load_threshold', (string) max(1, min(100, (float) ($data['load_threshold'] ?? 5))));
DnsSetting::set('load_alert_minutes', (string) max(1, min(60, (int) ($data['load_alert_minutes'] ?? 5))));
DnsSetting::clearCache();
Notification::make()->title(__('Notification settings saved'))->success()->send();
}
public function sendTestEmail(): void
{
$recipients = $this->notificationsData['admin_email_recipients'] ?? '';
if (empty($recipients)) {
Notification::make()->title(__('No recipients configured'))->body(__('Please add at least one admin email address'))->warning()->send();
return;
}
try {
$recipientList = array_map('trim', explode(',', $recipients));
$hostname = gethostname() ?: 'localhost';
$sender = "webmaster@{$hostname}";
$subject = __('Test Email');
$message = __('This is a test email from your Jabali Panel at :hostname.', ['hostname' => $hostname]).
"\n\n".__('If you received this email, your admin notifications are working correctly.');
Mail::raw(
$message,
function ($mail) use ($recipientList, $sender, $subject) {
$mail->from($sender, 'Jabali Panel');
$mail->to($recipientList);
$mail->subject('[Jabali] '.$subject);
}
);
// Log the test email
\App\Models\NotificationLog::log(
'test',
$subject,
$message,
$recipientList,
'sent'
);
Notification::make()->title(__('Test email sent'))->body(__('Check your inbox for the test email'))->success()->send();
} catch (Exception $e) {
// Log the failed test email
\App\Models\NotificationLog::log(
'test',
__('Test Email'),
__('This is a test email from your Jabali Panel.'),
array_map('trim', explode(',', $recipients)),
'failed',
null,
$e->getMessage()
);
Notification::make()->title(__('Failed to send test email'))->body($e->getMessage())->danger()->send();
}
}
public function saveFpmSettings(): void
{
$data = $this->phpFpmData;
DnsSetting::set('fpm_pm_max_children', (string) $data['pm_max_children']);
DnsSetting::set('fpm_pm_max_requests', (string) $data['pm_max_requests']);
DnsSetting::set('fpm_rlimit_files', (string) $data['rlimit_files']);
DnsSetting::set('fpm_process_priority', (string) $data['process_priority']);
DnsSetting::set('fpm_request_terminate_timeout', (string) $data['request_terminate_timeout']);
DnsSetting::set('fpm_memory_limit', $data['memory_limit']);
DnsSetting::clearCache();
Notification::make()
->title(__('PHP-FPM settings saved'))
->body(__('New user pools will use these settings. Use "Apply to All" to update existing pools.'))
->success()
->send();
}
public function applyFpmToAll(): void
{
$data = $this->phpFpmData;
try {
$result = $this->getAgent()->send('php.update_all_pool_limits', [
'pm_max_children' => (int) $data['pm_max_children'],
'pm_max_requests' => (int) $data['pm_max_requests'],
'rlimit_files' => (int) $data['rlimit_files'],
'process_priority' => (int) $data['process_priority'],
'request_terminate_timeout' => (int) $data['request_terminate_timeout'],
'memory_limit' => $data['memory_limit'],
]);
if ($result['success'] ?? false) {
$updated = $result['updated'] ?? [];
$errors = $result['errors'] ?? [];
if (empty($errors)) {
Notification::make()
->title(__('FPM pools updated'))
->body(__(':count user pools updated. PHP-FPM will reload.', ['count' => count($updated)]))
->success()
->send();
} else {
Notification::make()
->title(__('Partial update'))
->body(__(':success pools updated, :errors failed', [
'success' => count($updated),
'errors' => count($errors),
]))
->warning()
->send();
}
} else {
Notification::make()
->title(__('Failed to update pools'))
->body($result['error'] ?? __('Unknown error'))
->danger()
->send();
}
} catch (Exception $e) {
Notification::make()
->title(__('Failed to update pools'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function loadVersionInfo(): void
{
$versionFile = base_path('VERSION');
if (File::exists($versionFile)) {
$content = File::get($versionFile);
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
$this->currentVersion = trim($matches[1]);
if (preg_match('/BUILD=(\d+)/', $content, $buildMatches)) {
$this->currentVersion .= ' ('.__('build').' '.trim($buildMatches[1]).')';
}
}
} else {
$this->currentVersion = __('Unknown');
}
}
public function checkForUpdates(): void
{
$this->isChecking = true;
$this->updatesAvailable = 0;
try {
$basePath = base_path();
if (! is_dir("{$basePath}/.git")) {
throw new Exception(__('Not a git repository.'));
}
exec("cd {$basePath} && timeout 30 git fetch origin main 2>&1", $fetchOutput, $fetchCode);
if ($fetchCode !== 0) {
throw new Exception(__('Failed to fetch from repository.'));
}
$behindCount = trim(shell_exec("cd {$basePath} && git rev-list HEAD..origin/main --count 2>&1") ?? '0');
$this->updatesAvailable = (int) $behindCount;
if ($this->updatesAvailable > 0) {
$this->latestVersion = trim(shell_exec("cd {$basePath} && git log origin/main -1 --format='%s' 2>&1") ?? '');
Notification::make()->title(__('Updates Available'))->body(__(':count update(s) available', ['count' => $this->updatesAvailable]))->warning()->send();
} else {
Notification::make()->title(__('Up to Date'))->body(__('Running the latest version'))->success()->send();
}
} catch (Exception $e) {
Notification::make()->title(__('Update Check Failed'))->body($e->getMessage())->danger()->send();
}
$this->isChecking = false;
}
public function performUpgrade(): void
{
$this->isUpgrading = true;
$this->upgradeLog = __('Starting upgrade...')."\n";
try {
$exitCode = Artisan::call('jabali:upgrade', ['--force' => true]);
$this->upgradeLog .= Artisan::output();
if ($exitCode !== 0) {
throw new Exception(__('Upgrade failed. Check the log for details.'));
}
$this->loadVersionInfo();
$this->updatesAvailable = 0;
Notification::make()->title(__('Upgrade Complete'))->body(__('Refresh to see changes.'))->success()->send();
} catch (Exception $e) {
$this->upgradeLog .= "\n".__('Error').': '.$e->getMessage();
Notification::make()->title(__('Upgrade Failed'))->body($e->getMessage())->danger()->send();
}
$this->isUpgrading = false;
}
protected function getHeaderActions(): array
{
return [
$this->getTourAction(),
Action::make('export_config')
->label(__('Export'))
->icon('heroicon-o-arrow-down-tray')
->color('gray')
->action(fn () => $this->exportConfig()),
Action::make('import_config')
->label(__('Import'))
->icon('heroicon-o-arrow-up-tray')
->color('gray')
->modalHeading(__('Import Configuration'))
->modalDescription(__('Upload a previously exported configuration file. This will overwrite your current settings.'))
->modalIcon('heroicon-o-arrow-up-tray')
->modalIconColor('warning')
->modalSubmitActionLabel(__('Import'))
->form([
FileUpload::make('config_file')
->label(__('Configuration File'))
->acceptedFileTypes(['application/json'])
->required()
->maxSize(1024)
->helperText(__('Select a .json file exported from Jabali Panel')),
])
->action(fn (array $data) => $this->importConfig($data)),
];
}
public function exportConfig(): \Symfony\Component\HttpFoundation\StreamedResponse
{
$settings = DnsSetting::getAll();
unset($settings['custom_logo']);
$exportData = [
'version' => '1.0',
'exported_at' => now()->toIso8601String(),
'hostname' => gethostname(),
'settings' => $settings,
];
$filename = 'jabali-config-'.date('Y-m-d-His').'.json';
$content = json_encode($exportData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
Notification::make()->title(__('Configuration exported'))->success()->send();
return Response::streamDownload(function () use ($content) {
echo $content;
}, $filename, ['Content-Type' => 'application/json']);
}
public function importConfig(array $data): void
{
try {
if (empty($data['config_file'])) {
throw new Exception(__('No file uploaded'));
}
$filePath = Storage::disk('local')->path($data['config_file']);
if (! file_exists($filePath)) {
throw new Exception(__('Uploaded file not found'));
}
$content = file_get_contents($filePath);
$importData = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception(__('Invalid JSON file: :error', ['error' => json_last_error_msg()]));
}
if (! isset($importData['settings']) || ! is_array($importData['settings'])) {
throw new Exception(__('Invalid configuration file format'));
}
$imported = 0;
foreach ($importData['settings'] as $key => $value) {
if (in_array($key, ['custom_logo'])) {
continue;
}
DnsSetting::set($key, $value);
$imported++;
}
DnsSetting::clearCache();
Storage::disk('local')->delete($data['config_file']);
$this->mount();
Notification::make()->title(__('Configuration imported'))->body(__(':count settings imported successfully', ['count' => $imported]))->success()->send();
} catch (Exception $e) {
Notification::make()->title(__('Import failed'))->body($e->getMessage())->danger()->send();
}
}
}