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), ]; $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)')), ]), 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); 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(); } } else { 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(); } } }