import { useState, useEffect, useRef } 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 { Skeleton } from '@/components/ui/skeleton'; import { Badge } from '@/components/ui/badge'; 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, Database, Info } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; interface Agent { type: 'discord' | 'slack' | 'webhook'; url: string; enabled: boolean; } // Keys that the settings PATCH endpoint accepts interface PatchableSettings { host_cpu_limit?: string; host_ram_limit?: string; host_disk_limit?: string; docker_janitor_gb?: string; global_crash?: '0' | '1'; global_logs_refresh?: '1' | '3' | '5' | '10'; developer_mode?: '0' | '1'; template_registry_url?: string; metrics_retention_hours?: string; log_retention_days?: string; } type SectionId = 'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'; interface SettingsModalProps { isOpen: boolean; onClose: () => void; isDarkMode: boolean; setIsDarkMode: (mode: boolean) => void; } const DEFAULT_SETTINGS: PatchableSettings = { 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', template_registry_url: '', metrics_retention_hours: '24', log_retention_days: '30', }; export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) { const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('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: '' }); // Notification agents state const [agents, setAgents] = useState>({ discord: { type: 'discord', url: '', enabled: false }, slack: { type: 'slack', url: '', enabled: false }, webhook: { type: 'webhook', url: '', enabled: false }, }); // Settings state — all user-configurable keys (no auth keys) const [settings, setSettings] = useState({ ...DEFAULT_SETTINGS }); // Track server state to detect unsaved changes without causing re-renders const serverSettingsRef = useRef({ ...DEFAULT_SETTINGS }); // Per-operation loading states const [isSettingsLoading, setIsSettingsLoading] = useState(false); const [isSavingSystem, setIsSavingSystem] = useState(false); const [isSavingDeveloper, setIsSavingDeveloper] = useState(false); const [isSavingPassword, setIsSavingPassword] = useState(false); const [isSavingRegistry, setIsSavingRegistry] = useState(false); const [isSavingAgent, setIsSavingAgent] = useState>({}); const [isTestingAgent, setIsTestingAgent] = useState>({}); // Unsaved changes indicators per section (compared against server ref) const hasSystemChanges = settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit || settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit || settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit || settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb || settings.global_crash !== serverSettingsRef.current.global_crash; const hasDeveloperChanges = settings.developer_mode !== serverSettingsRef.current.developer_mode || settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh || settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours || settings.log_retention_days !== serverSettingsRef.current.log_retention_days; useEffect(() => { if (isOpen) { fetchAgents(); fetchSettings(); } }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAgents = async () => { try { const res = await apiFetch('/agents'); if (res.ok) { const data: Agent[] = await res.json(); setAgents(prev => { const next = { ...prev }; data.forEach(a => { next[a.type] = a; }); return next; }); } } catch (e) { console.error('Failed to fetch agents', e); } }; const fetchSettings = async () => { setIsSettingsLoading(true); try { // Fetch per-node settings from the active node (system limits etc.) const nodeRes = await apiFetch('/settings'); // Always fetch developer/UI preferences from local — these control // this Sencho instance's behaviour and must never be proxied to remote const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes; const nodeData: Record = nodeRes.ok ? await nodeRes.json() : {}; const localData: Record = (isRemote && localRes.ok) ? await localRes.json() : nodeData; const safe: PatchableSettings = { // Per-node: read from active node host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit, host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit, host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit, docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb, global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash, template_registry_url: nodeData.template_registry_url ?? '', // Local-only: always read from local node global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh, developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode, metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, }; setSettings(safe); serverSettingsRef.current = { ...safe }; } catch (e) { console.error('Failed to fetch settings', e); } finally { setIsSettingsLoading(false); } }; const handleSettingChange = (key: K, value: PatchableSettings[K]) => { setSettings(prev => ({ ...prev, [key]: value })); }; const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void, localOnly = false): Promise => { setLoading(true); try { const res = await apiFetch('/settings', { method: 'PATCH', body: JSON.stringify(payload), localOnly, }); if (!res.ok) { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to save settings.'); return false; } serverSettingsRef.current = { ...serverSettingsRef.current, ...payload }; return true; } catch (e: unknown) { toast.error((e as Error)?.message || 'Something went wrong.'); return false; } finally { setLoading(false); } }; const saveSystemSettings = async () => { const ok = await patchSettings({ host_cpu_limit: settings.host_cpu_limit, host_ram_limit: settings.host_ram_limit, host_disk_limit: settings.host_disk_limit, docker_janitor_gb: settings.docker_janitor_gb, global_crash: settings.global_crash, }, setIsSavingSystem); if (ok) toast.success('System limits saved.'); }; const saveDeveloperSettings = async () => { // Developer/UI preferences are local-only — never proxy to remote node const ok = await patchSettings({ developer_mode: settings.developer_mode, global_logs_refresh: settings.global_logs_refresh, metrics_retention_hours: settings.metrics_retention_hours, log_retention_days: settings.log_retention_days, }, setIsSavingDeveloper, true); if (ok) toast.success('Developer settings saved.'); }; const saveRegistrySettings = async () => { setIsSavingRegistry(true); try { const res = await apiFetch('/settings', { method: 'PATCH', body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to save registry settings.'); return; } serverSettingsRef.current = { ...serverSettingsRef.current, template_registry_url: settings.template_registry_url }; await apiFetch('/templates/refresh-cache', { method: 'POST' }); toast.success('Registry saved. App Store will reload from the new source.'); } catch (e: unknown) { toast.error((e as Error)?.message || 'Failed to save registry settings.'); } finally { setIsSavingRegistry(false); } }; const handleAgentChange = (type: string, field: keyof Agent, value: Agent[keyof Agent]) => { setAgents(prev => ({ ...prev, [type]: { ...prev[type], [field]: value } })); }; const saveAgent = async (type: string) => { setIsSavingAgent(prev => ({ ...prev, [type]: 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.`); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Something went wrong.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Network error.'); } finally { setIsSavingAgent(prev => ({ ...prev, [type]: false })); } }; const testAgent = async (type: string) => { if (!agents[type].url) { toast.error('Please enter a webhook URL first.'); return; } setIsTestingAgent(prev => ({ ...prev, [type]: 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().catch(() => ({})); toast.error(err?.details || err?.error || 'Test failed.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Network error.'); } finally { setIsTestingAgent(prev => ({ ...prev, [type]: 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; } if (authData.newPassword.length < 6) { toast.error('New password must be at least 6 characters'); return; } setIsSavingPassword(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().catch(() => ({})); toast.error(data?.error || 'Failed to update password'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Network error during password change'); } finally { setIsSavingPassword(false); } }; const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
handleAgentChange(type, 'enabled', c)} />
handleAgentChange(type, 'url', e.target.value)} />
); const SettingsSkeleton = () => (
); const NavButton = ({ section, icon, label, showDot }: { section: SectionId; icon: React.ReactNode; label: string; showDot?: boolean }) => ( ); return ( !open && onClose()}> {/* Sidebar */}
Settings Hub
{isRemote ? (
{activeNode!.name}
) : (
)}
{/* 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 alert thresholds and crash detection.

{isRemote && ( Configuring: {activeNode!.name} )}
{isSettingsLoading ? : ( <>
{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-[150px]" /> GB reclaimable

Alert when unused Docker data exceeds this size.

Watch all containers for unexpected exits

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 data retention.

{isRemote && ( Always Local These settings control this Sencho instance's UI behaviour and are never synced to remote nodes. )}
{isSettingsLoading ? : ( <>

Enable Real-Time Metrics & Extended Logs

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

SSE streaming is active — polling rate is overridden.

)}
{/* Data Retention (Observability) */}
Data Retention

How long to keep per-container CPU/RAM/network history.

handleSettingChange('metrics_retention_hours', e.target.value)} className="w-20" /> hrs

How long to keep alert and notification history.

handleSettingChange('log_retention_days', e.target.value)} className="w-20" /> days
)}
)} {activeSection === 'nodes' && ( )} {activeSection === 'appstore' && (

App Store Registry

Configure the template source used by the App Store.

{isSettingsLoading ? : ( <>

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.

handleSettingChange('template_registry_url', e.target.value)} />

Leave empty to use the default LinuxServer.io registry.

)}
)}
); }