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', 'database' => $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] ?? '']; } if (trim($searchDomain) === 'example.com') { $searchDomain = ''; } // 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), ]; $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', ]; } 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(), 'database' => $this->databaseTabContent(), default => $this->generalTabContent(), }; } protected function generalTabContent(): array { return [ 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([ Actions::make([ FormAction::make('applyCloudflareResolvers') ->label(__('Use Cloudflare')) ->action(fn () => $this->applyResolverTemplate('cloudflare')), FormAction::make('applyGoogleResolvers') ->label(__('Use Google')) ->action(fn () => $this->applyResolverTemplate('google')), FormAction::make('applyQuad9Resolvers') ->label(__('Use Quad9')) ->action(fn () => $this->applyResolverTemplate('quad9')), ])->alignment('left'), 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 applyResolverTemplate(string $template): void { $templates = [ 'cloudflare' => ['1.1.1.1', '1.0.0.1', '2606:4700:4700::1111'], 'google' => ['8.8.8.8', '8.8.4.4', '2001:4860:4860::8888'], 'quad9' => ['9.9.9.9', '149.112.112.112', '2620:fe::fe'], ]; $resolvers = $templates[$template] ?? null; if (! $resolvers) { return; } $current = $this->resolversData ?? []; $this->resolversData = array_merge($current, [ 'resolver1' => $resolvers[0] ?? '', 'resolver2' => $resolvers[1] ?? '', 'resolver3' => $resolvers[2] ?? '', ]); Notification::make() ->title(__('DNS resolver template applied')) ->success() ->send(); } 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)')), ]), 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 & High Load Alerts')) ->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')), ]), 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 databaseTabContent(): array { return [ Section::make(__('Database Tuning')) ->description(__('Adjust MariaDB/MySQL global variables.')) ->icon('heroicon-o-circle-stack') ->schema([ EmbeddedTable::make(DatabaseTuningTable::class), ]), ]; } 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); DnsSetting::set('quotas_enabled', $data['quotas_enabled'] ? '1' : '0'); DnsSetting::set('default_quota_mb', (string) $data['default_quota_mb']); 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(); } } 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; $emails = $this->parseNotificationRecipients($data['admin_email_recipients'] ?? '', false); if ($emails === null) { return; } $emailsValue = $emails === [] ? '' : implode(', ', $emails); $this->notificationsData['admin_email_recipients'] = $emailsValue; DnsSetting::set('admin_email_recipients', $emailsValue); 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'] ?? ''; $recipientList = $this->parseNotificationRecipients($recipients, true); if ($recipientList === null) { return; } try { $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(); } } /** * @return array|null */ protected function parseNotificationRecipients(?string $recipients, bool $requireOne): ?array { $value = trim((string) $recipients); if ($value === '') { if ($requireOne) { $this->addError('notificationsData.admin_email_recipients', __('Please add at least one email address.')); Notification::make()->title(__('Email Addresses required'))->body(__('Please add at least one email address in Email Addresses.'))->warning()->send(); return null; } return []; } if (str_contains($value, ';')) { $this->addError('notificationsData.admin_email_recipients', __('Use a comma-separated list of email addresses.')); Notification::make()->title(__('Invalid recipient format'))->body(__('Use commas to separate email addresses.'))->danger()->send(); return null; } $emails = array_values(array_filter(array_map('trim', explode(',', $value)), fn (string $email): bool => $email !== '')); if ($requireOne && $emails === []) { $this->addError('notificationsData.admin_email_recipients', __('Please add at least one email address.')); Notification::make()->title(__('Email Addresses required'))->body(__('Please add at least one email address in Email Addresses.'))->warning()->send(); return null; } foreach ($emails as $email) { if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { $this->addError('notificationsData.admin_email_recipients', __('Use a comma-separated list of valid email addresses.')); Notification::make()->title(__('Invalid recipient email'))->body(__(':email is not a valid email address', ['email' => $email]))->danger()->send(); return null; } } return $emails; } 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 getHeaderActions(): array { return [ 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 { $exportData = $this->buildExportPayload(); $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')); } $importedSettings = 0; foreach ($importData['settings'] as $key => $value) { if (in_array($key, ['custom_logo'])) { continue; } DnsSetting::set($key, $value); $importedSettings++; } $importedPackages = 0; if (isset($importData['hosting_packages']) && is_array($importData['hosting_packages'])) { $importedPackages = $this->importHostingPackages($importData['hosting_packages']); } $tokenSummary = ['imported' => 0, 'skipped' => 0]; if (isset($importData['api_tokens']) && is_array($importData['api_tokens'])) { $tokenSummary = $this->importApiTokens($importData['api_tokens']); } $tuningSummary = ['applied' => 0, 'failed' => 0]; if (isset($importData['database_tuning']) && is_array($importData['database_tuning'])) { $tuningSummary = $this->applyDatabaseTuningFromImport($importData['database_tuning']); } DnsSetting::clearCache(); Storage::disk('local')->delete($data['config_file']); $this->mount(); $message = __(':settings settings imported, :packages packages updated, :tokens tokens imported, :tuning tuning entries applied', [ 'settings' => $importedSettings, 'packages' => $importedPackages, 'tokens' => $tokenSummary['imported'], 'tuning' => $tuningSummary['applied'], ]); Notification::make()->title(__('Configuration imported'))->body($message)->success()->send(); } catch (Exception $e) { Notification::make()->title(__('Import failed'))->body($e->getMessage())->danger()->send(); } } /** * @return array */ public function buildExportPayload(): array { $settings = DnsSetting::getAll(); unset($settings['custom_logo']); return [ 'version' => '1.1', 'exported_at' => now()->toIso8601String(), 'hostname' => gethostname(), 'settings' => $settings, 'hosting_packages' => HostingPackage::query() ->orderBy('name') ->get() ->map(fn (HostingPackage $package): array => [ 'name' => $package->name, 'description' => $package->description, 'disk_quota_mb' => $package->disk_quota_mb, 'bandwidth_gb' => $package->bandwidth_gb, 'domains_limit' => $package->domains_limit, 'databases_limit' => $package->databases_limit, 'mailboxes_limit' => $package->mailboxes_limit, 'is_active' => $package->is_active, ]) ->toArray(), 'api_tokens' => $this->exportApiTokens(), 'database_tuning' => $this->getDatabaseTuningSettings(), ]; } /** * @return array> */ protected function exportApiTokens(): array { $adminUsers = User::query() ->where('is_admin', true) ->get(['id', 'email', 'username']); $tokens = []; foreach ($adminUsers as $admin) { foreach ($admin->tokens as $token) { $tokens[] = [ 'name' => $token->name, 'token' => $token->token, 'abilities' => $token->abilities ?? [], 'last_used_at' => $token->last_used_at?->toIso8601String(), 'expires_at' => $token->expires_at?->toIso8601String(), 'created_at' => $token->created_at?->toIso8601String(), 'owner_email' => $admin->email, 'owner_username' => $admin->username, ]; } } return $tokens; } /** * @return array */ protected function getDatabaseTuningSettings(): array { $paths = [ '/etc/mysql/mariadb.conf.d/90-jabali-tuning.cnf', '/etc/mysql/conf.d/90-jabali-tuning.cnf', ]; foreach ($paths as $path) { if (! file_exists($path)) { continue; } $lines = file($path, FILE_IGNORE_NEW_LINES) ?: []; $settings = []; foreach ($lines as $line) { $line = trim($line); if ($line === '' || str_starts_with($line, '#') || str_starts_with($line, '[')) { continue; } if (! str_contains($line, '=')) { continue; } [$key, $value] = array_map('trim', explode('=', $line, 2)); if ($key !== '') { $settings[$key] = $value; } } return $settings; } return []; } /** * @param array> $packages */ protected function importHostingPackages(array $packages): int { $imported = 0; foreach ($packages as $package) { if (! is_array($package) || empty($package['name'])) { continue; } HostingPackage::updateOrCreate( ['name' => $package['name']], [ 'description' => $package['description'] ?? '', 'disk_quota_mb' => $package['disk_quota_mb'] ?? null, 'bandwidth_gb' => $package['bandwidth_gb'] ?? null, 'domains_limit' => $package['domains_limit'] ?? null, 'databases_limit' => $package['databases_limit'] ?? null, 'mailboxes_limit' => $package['mailboxes_limit'] ?? null, 'is_active' => (bool) ($package['is_active'] ?? true), ] ); $imported++; } return $imported; } /** * @param array> $tokens * @return array{imported:int,skipped:int} */ protected function importApiTokens(array $tokens): array { $imported = 0; $skipped = 0; foreach ($tokens as $token) { if (! is_array($token) || empty($token['token'])) { $skipped++; continue; } $ownerEmail = $token['owner_email'] ?? null; $ownerUsername = $token['owner_username'] ?? null; $owner = User::query() ->where('is_admin', true) ->when($ownerEmail, fn ($query) => $query->where('email', $ownerEmail)) ->when(! $ownerEmail && $ownerUsername, fn ($query) => $query->where('username', $ownerUsername)) ->first(); if (! $owner) { $skipped++; continue; } $tokenHash = $token['token']; $exists = DB::table('personal_access_tokens')->where('token', $tokenHash)->exists(); if ($exists) { $skipped++; continue; } DB::table('personal_access_tokens')->insert([ 'tokenable_type' => User::class, 'tokenable_id' => $owner->id, 'name' => $token['name'] ?? 'API Token', 'token' => $tokenHash, 'abilities' => json_encode($token['abilities'] ?? []), 'last_used_at' => $token['last_used_at'] ?? null, 'expires_at' => $token['expires_at'] ?? null, 'created_at' => $token['created_at'] ?? now()->toIso8601String(), 'updated_at' => now()->toIso8601String(), ]); $imported++; } return ['imported' => $imported, 'skipped' => $skipped]; } /** * @param array $tuning * @return array{applied:int,failed:int} */ protected function applyDatabaseTuningFromImport(array $tuning): array { $applied = 0; $failed = 0; foreach ($tuning as $name => $value) { if (! is_string($name) || $name === '') { $failed++; continue; } try { $agent = new AgentClient; $result = $agent->databasePersistTuning($name, (string) $value); if ($result['success'] ?? false) { $applied++; } else { $failed++; } } catch (Exception $e) { $failed++; } } return ['applied' => $applied, 'failed' => $failed]; } }