import { useState, useEffect } from 'react'; import { Dialog, DialogContent, } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; interface Agent { type: 'discord' | 'slack' | 'webhook'; url: string; enabled: boolean; } interface SettingsModalProps { isOpen: boolean; onClose: () => void; isDarkMode: boolean; setIsDarkMode: (mode: boolean) => void; } export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) { const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'>('account'); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps // Auth State const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); // Notifications State const [agents, setAgents] = useState>({ discord: { type: 'discord', url: '', enabled: false }, slack: { type: 'slack', url: '', enabled: false }, webhook: { type: 'webhook', url: '', enabled: false }, }); // System Settings State const [settings, setSettings] = useState>({ host_cpu_limit: '90', host_ram_limit: '90', host_disk_limit: '90', global_crash: '1', docker_janitor_gb: '5', global_logs_refresh: '5', developer_mode: '0' }); const [isLoading, setIsLoading] = useState(false); const [registryUrl, setRegistryUrl] = useState(''); const [isSavingRegistry, setIsSavingRegistry] = useState(false); useEffect(() => { if (isOpen) { fetchAgents(); fetchSettings(); } }, [isOpen]); const fetchAgents = async () => { try { const res = await apiFetch('/agents'); if (res.ok) { const data: Agent[] = await res.json(); const newAgents = { ...agents }; data.forEach(a => { newAgents[a.type] = a; }); setAgents(newAgents); } } catch (e) { console.error('Failed to fetch agents', e); } }; const fetchSettings = async () => { try { const res = await apiFetch('/settings'); if (res.ok) { const data = await res.json(); setSettings(prev => ({ ...prev, ...data })); if (data.template_registry_url) { setRegistryUrl(data.template_registry_url); } } } catch (e) { console.error('Failed to fetch settings', e); } }; const saveRegistrySettings = async () => { setIsSavingRegistry(true); try { await apiFetch('/settings', { method: 'POST', body: JSON.stringify({ key: 'template_registry_url', value: registryUrl.trim() }) }); // Bust the template cache so the next App Store load uses the new URL await apiFetch('/templates/refresh-cache', { method: 'POST' }); toast.success('Registry saved. App Store will reload from the new source.'); } catch (e) { toast.error('Failed to save registry settings.'); } finally { setIsSavingRegistry(false); } }; const handleAgentChange = (type: string, field: keyof Agent, value: any) => { setAgents(prev => ({ ...prev, [type]: { ...prev[type], [field]: value } })); }; const handleSettingChange = (key: string, value: string) => { setSettings(prev => ({ ...prev, [key]: value })); }; const saveAgent = async (type: string) => { setIsLoading(true); try { const res = await apiFetch('/agents', { method: 'POST', body: JSON.stringify(agents[type]) }); if (res.ok) { toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved successfully.`); } else { toast.error(`Failed to save ${type} settings.`); } } catch (e) { toast.error('Network error.'); } finally { setIsLoading(false); } }; const testAgent = async (type: string) => { if (!agents[type].url) { toast.error('Please enter a webhook URL first.'); return; } setIsLoading(true); try { const res = await apiFetch('/notifications/test', { method: 'POST', body: JSON.stringify({ type, url: agents[type].url }) }); if (res.ok) { toast.success('Test notification sent!'); } else { const err = await res.json(); toast.error(err.details || 'Test failed.'); } } catch (e) { toast.error('Network error.'); } finally { setIsLoading(false); } }; const saveSettings = async () => { setIsLoading(true); try { for (const [key, value] of Object.entries(settings)) { await apiFetch('/settings', { method: 'POST', body: JSON.stringify({ key, value }) }); } toast.success('System limits & watchdog settings saved.'); } catch (e) { toast.error('Failed to save settings.'); } finally { setIsLoading(false); } }; const handlePasswordChange = async () => { if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) { toast.error("All fields are required"); return; } if (authData.newPassword !== authData.confirmPassword) { toast.error("New passwords do not match"); return; } setIsLoading(true); try { const res = await apiFetch('/auth/password', { method: 'PUT', body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword }) }); if (res.ok) { toast.success('Password updated successfully'); setAuthData({ oldPassword: '', newPassword: '', confirmPassword: '' }); } else { const data = await res.json(); toast.error(data.error || 'Failed to update password'); } } catch (e) { toast.error('Network error during password change'); } finally { setIsLoading(false); } }; const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
handleAgentChange(type, 'enabled', c)} />
handleAgentChange(type, 'url', e.target.value)} />
); return ( !open && onClose()}> {/* Sidebar */}
Settings Hub
{isRemote && (
{activeNode!.name}
)} {!isRemote &&
}
{/* Main Content Area */}
{activeSection === 'account' && (

Account & Security

Manage your credentials and authentication.

setAuthData(prev => ({ ...prev, oldPassword: e.target.value }))} />
setAuthData(prev => ({ ...prev, newPassword: e.target.value }))} />
setAuthData(prev => ({ ...prev, confirmPassword: e.target.value }))} />
)} {activeSection === 'system' && (

System Limits & Watchdog

Configure auto-recovery thresholds and server constraints.

{settings.host_cpu_limit}%
handleSettingChange('host_cpu_limit', v[0].toString())} />
{settings.host_ram_limit}%
handleSettingChange('host_ram_limit', v[0].toString())} />
{settings.host_disk_limit}%
handleSettingChange('host_disk_limit', v[0].toString())} />
handleSettingChange('docker_janitor_gb', e.target.value)} className="max-w-[200px]" />

Watch all containers indefinitely

handleSettingChange('global_crash', c ? '1' : '0')} />
)} {activeSection === 'notifications' && (

Notifications & Alerts

Configure external integrations for crash alerts.

Discord Slack Webhook {renderAgentTab('discord', 'Discord')} {renderAgentTab('slack', 'Slack')} {renderAgentTab('webhook', 'Custom Webhook')}
)} {activeSection === 'appearance' && (

Appearance

Customize Sencho's visual theme.

)} {activeSection === 'developer' && (

Developer

Power user settings for real-time observability and extended diagnostics.

Enable Real-Time Metrics & Extended Logs

handleSettingChange('developer_mode', c ? '1' : '0')} />
{settings.developer_mode === '1' && (

SSE streaming is active - polling rate is overridden by real-time streaming.

)}
)} {activeSection === 'nodes' && ( )} {activeSection === 'appstore' && (

App Store Registry

Configure the template source used by the App Store.

LinuxServer.io — https://api.linuxserver.io/api/v1/images

Used when no custom registry is set.

Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry.

setRegistryUrl(e.target.value)} />

Leave empty to use the default LinuxServer.io registry.

)}
); }