import { useState, useEffect, useRef } from 'react'; import { motion } from 'motion/react'; import { Dialog, DialogContent, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; 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, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, Check, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard, LifeBuoy, Book, Mail, Bug, Zap } from 'lucide-react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { TierBadge } from './TierBadge'; import { ProGate } from './ProGate'; import { SSOSection } from './SSOSection'; 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' | 'license' | 'users' | 'sso' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about'; interface WebhookItem { id: number; name: string; stack_name: string; action: string; secret: string; enabled: boolean; created_at: number; updated_at: number; } interface WebhookExecution { id: number; webhook_id: number; action: string; status: 'success' | 'failure'; trigger_source: string | null; duration_ms: number | null; error: string | null; executed_at: number; } interface SettingsModalProps { isOpen: boolean; onClose: () => 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', }; function WebhooksSection({ isPro }: { isPro: boolean }) { const [webhooks, setWebhooks] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [showForm, setShowForm] = useState(false); const [newSecret, setNewSecret] = useState<{ id: number; secret: string } | null>(null); const [expandedHistory, setExpandedHistory] = useState(null); const [history, setHistory] = useState>({}); const [loadingHistory, setLoadingHistory] = useState(null); // Form state const [formName, setFormName] = useState(''); const [formStack, setFormStack] = useState(''); const [formAction, setFormAction] = useState('deploy'); const [stacks, setStacks] = useState([]); const fetchWebhooks = async () => { try { const res = await apiFetch('/webhooks', { localOnly: true }); if (res.ok) setWebhooks(await res.json()); } catch { /* ignore */ } finally { setLoading(false); } }; const fetchStacks = async () => { try { const res = await apiFetch('/stacks'); if (res.ok) setStacks(await res.json()); } catch { /* ignore */ } }; useEffect(() => { fetchWebhooks(); fetchStacks(); }, []); const handleCreate = async () => { if (!formName || !formStack || !formAction) { toast.error('All fields are required.'); return; } setCreating(true); try { const res = await apiFetch('/webhooks', { method: 'POST', localOnly: true, body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction }), }); if (res.ok) { const data = await res.json(); setNewSecret({ id: data.id, secret: data.secret }); setShowForm(false); setFormName(''); setFormStack(''); setFormAction('deploy'); fetchWebhooks(); toast.success('Webhook created.'); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to create webhook.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Network error.'); } finally { setCreating(false); } }; const handleDelete = async (id: number) => { try { const res = await apiFetch(`/webhooks/${id}`, { method: 'DELETE', localOnly: true }); if (res.ok) { toast.success('Webhook deleted.'); fetchWebhooks(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to delete.'); } } catch { toast.error('Network error.'); } }; const handleToggle = async (id: number, enabled: boolean) => { try { const res = await apiFetch(`/webhooks/${id}`, { method: 'PUT', localOnly: true, body: JSON.stringify({ enabled }), }); if (res.ok) fetchWebhooks(); } catch { /* ignore */ } }; const fetchHistory = async (webhookId: number) => { if (expandedHistory === webhookId) { setExpandedHistory(null); return; } setExpandedHistory(webhookId); setLoadingHistory(webhookId); try { const res = await apiFetch(`/webhooks/${webhookId}/history`, { localOnly: true }); if (res.ok) { const data = await res.json(); setHistory(prev => ({ ...prev, [webhookId]: data })); } } catch { /* ignore */ } finally { setLoadingHistory(null); } }; const copyToClipboard = (text: string, label: string) => { navigator.clipboard.writeText(text); toast.success(`${label} copied to clipboard.`); }; if (!isPro) { return (

Webhooks

Trigger stack actions from CI/CD pipelines via HTTP.

); } return (

Webhooks

Trigger stack actions from CI/CD pipelines via HTTP.

{/* Create Form */} {showForm && (
setFormName(e.target.value)} />
)} {/* Secret reveal (shown once after creation) */} {newSecret && (
Webhook created - copy your secret now

This secret will not be shown again. Store it securely.

{newSecret.secret}
)} {/* Loading state */} {loading && (
)} {/* Empty state */} {!loading && webhooks.length === 0 && !showForm && (

No webhooks configured yet.

Create one to trigger stack actions from CI/CD.

)} {/* Webhook list */} {!loading && webhooks.map(wh => { const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`; const isExpanded = expandedHistory === wh.id; return (
{wh.name} {wh.action} {wh.stack_name}
handleToggle(wh.id!, c)} />
{/* Trigger URL */}
{triggerUrl}
{/* Secret (masked) */}
Secret: {wh.secret}
{/* History toggle */}
{/* Execution history */} {isExpanded && (
{loadingHistory === wh.id ? ( ) : (history[wh.id!] ?? []).length === 0 ? (

No executions yet.

) : (
{(history[wh.id!] ?? []).map(ex => (
{ex.status === 'success' ? : } {ex.action} {new Date(ex.executed_at).toLocaleString()} {ex.duration_ms !== null && ( {(ex.duration_ms / 1000).toFixed(1)}s )} {ex.error && ( {ex.error} )}
))}
)}
)}
); })}
); } interface UserItem { id: number; username: string; role: 'admin' | 'viewer'; created_at: number; } function UsersSection() { const { user: currentUser } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingUser, setEditingUser] = useState(null); const [saving, setSaving] = useState(false); // Form state const [formUsername, setFormUsername] = useState(''); const [formPassword, setFormPassword] = useState(''); const [formConfirmPassword, setFormConfirmPassword] = useState(''); const [formRole, setFormRole] = useState<'admin' | 'viewer'>('viewer'); const fetchUsers = async () => { try { const res = await apiFetch('/users', { localOnly: true }); if (res.ok) setUsers(await res.json()); } catch { /* ignore */ } finally { setLoading(false); } }; useEffect(() => { fetchUsers(); }, []); const resetForm = () => { setFormUsername(''); setFormPassword(''); setFormConfirmPassword(''); setFormRole('viewer'); setEditingUser(null); setShowForm(false); }; const handleSave = async () => { if (!formUsername || formUsername.length < 3) { toast.error('Username must be at least 3 characters.'); return; } if (!/^[a-zA-Z0-9_-]+$/.test(formUsername)) { toast.error('Username can only contain letters, numbers, underscores, and hyphens.'); return; } if (!editingUser && !formPassword) { toast.error('Password is required for new users.'); return; } if (formPassword && formPassword.length < 6) { toast.error('Password must be at least 6 characters.'); return; } if (formPassword && formPassword !== formConfirmPassword) { toast.error('Passwords do not match.'); return; } setSaving(true); try { if (editingUser) { const body: Record = { username: formUsername, role: formRole }; if (formPassword) body.password = formPassword; const res = await apiFetch(`/users/${editingUser.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), localOnly: true, }); if (!res.ok) { const err = await res.json(); toast.error(err?.error || err?.message || 'Failed to update user.'); return; } toast.success('User updated.'); } else { const res = await apiFetch('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: formUsername, password: formPassword, role: formRole }), localOnly: true, }); if (!res.ok) { const err = await res.json(); toast.error(err?.error || err?.message || 'Failed to create user.'); return; } toast.success('User created.'); } resetForm(); fetchUsers(); } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Something went wrong.'; toast.error(msg); } finally { setSaving(false); } }; const handleDelete = async (userId: number) => { try { const res = await apiFetch(`/users/${userId}`, { method: 'DELETE', localOnly: true }); if (!res.ok) { const err = await res.json(); toast.error(err?.error || err?.message || 'Failed to delete user.'); return; } toast.success('User deleted.'); fetchUsers(); } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Something went wrong.'; toast.error(msg); } }; const startEdit = (u: UserItem) => { setEditingUser(u); setFormUsername(u.username); setFormRole(u.role); setFormPassword(''); setFormConfirmPassword(''); setShowForm(true); }; return (

User Management

Create and manage user accounts with role-based access control.

{!showForm && ( )}
{/* Add/Edit Form */} {showForm && (

{editingUser ? 'Edit User' : 'New User'}

setFormUsername(e.target.value)} placeholder="username" />
setFormPassword(e.target.value)} placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'} />
setFormConfirmPassword(e.target.value)} placeholder="Confirm password" />
)} {/* Users Table */} {loading ? (
) : users.length === 0 ? (
No users found.
) : (
{users.map((u) => { const isSelf = u.username === currentUser?.username; return ( ); })}
Username Role Created Actions
{u.username} {isSelf && (you)} {u.role} {new Date(u.created_at).toLocaleDateString()}
Delete user "{u.username}"? This action cannot be undone. The user will lose access immediately. Cancel handleDelete(u.id)}>Delete
)}
); } export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); const { license, isPro, activate, deactivate } = useLicense(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('account'); const [licenseKeyInput, setLicenseKeyInput] = useState(''); const [isActivating, setIsActivating] = useState(false); const [isDeactivating, setIsDeactivating] = useState(false); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps // Notification tab state (controlled for sliding indicator) const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord'); // 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, activeNode?.id]); // 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()}> Settings Hub Configure Sencho settings {/* 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 === 'license' && (

License

Manage your Sencho Pro license.

{/* Current Tier Display */}
{license?.tier === 'pro' ? ( ) : ( )} {license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
{license?.status === 'trial' && license.trialDaysRemaining !== null && (
Trial: {license.trialDaysRemaining} day{license.trialDaysRemaining !== 1 ? 's' : ''} remaining
)} {license?.status === 'active' && (
{license.customerName && (
Customer {license.customerName}
)} {license.productName && (
Plan {license.productName}
)} {license.maskedKey && (
License Key {license.maskedKey}
)} {license.validUntil && (
Renews {new Date(license.validUntil).toLocaleDateString()}
)}
)} {license?.status === 'expired' && (
Your Pro license has expired. Renew to restore Pro features.
)} {license?.status === 'disabled' && (
Your license has been disabled. Contact support for assistance.
)}
{/* Manage Subscription (active Pro) */} {license?.status === 'active' && (
{license.portalUrl && ( )}

Deactivating will revert to Community features.

)} {/* Upgrade Cards — Community: show both, Personal Pro: show Team only, Team Pro: none */} {(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
{/* Personal Pro Card — only for Community users */} {license?.tier !== 'pro' && (
Personal Pro Popular

Professional tools for solo operators.

    {['Fleet View with drill-down', 'RBAC viewer accounts (1 + 3)', 'Custom webhooks', 'Atomic deployment', 'Fleet-wide backups'].map((f) => (
  • {f}
  • ))}
)} {/* Team Pro Card */}
Team Pro

For teams managing shared infrastructure.

    {[ ...(license?.variant === 'personal' ? ['Everything in Personal Pro'] : ['Everything in Community']), 'Unlimited admin accounts', 'Unlimited viewer accounts', ...(license?.variant !== 'personal' ? ['Fleet View & webhooks', 'Atomic deployment & backups'] : []), 'Team onboarding assistance', ].map((f) => (
  • {f}
  • ))}
)} {/* License key activation — show when not active */} {license?.status !== 'active' && (
setLicenseKeyInput(e.target.value)} className="font-mono" />
)}
)} {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

{isRemote ? <>Configuring notification channels on {activeNode!.name}. Alerts from this remote node will dispatch via these channels. : 'Configure external integrations for crash alerts.' }

{isRemote && ( Remote These channels are saved on the remote Sencho instance and used when it dispatches alerts. )}
setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> {notifTab === 'discord' && ( )} Discord {notifTab === 'slack' && ( )} Slack {notifTab === 'webhook' && ( )} Webhook {renderAgentTab('discord', 'Discord')} {renderAgentTab('slack', 'Slack')} {renderAgentTab('webhook', 'Custom Webhook')}
)} {activeSection === 'webhooks' && ( )} {activeSection === 'users' && ( )} {activeSection === 'sso' && ( )} {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 === 'support' && (

Help & Support

Get help with Sencho based on your plan.

{/* Self-serve channels (all tiers) */} {/* Pro support channels */} {isPro && ( )} {/* Upsell for Community */} {!isPro && (

Need faster support?

Upgrade to Pro for direct email support and priority issue handling.

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

About Sencho

Version and instance information.

Version v{__APP_VERSION__}
Tier
License Status {license?.status ?? 'community'}
{license?.instanceId && (
Instance ID {license.instanceId.slice(0, 8)}
)}
)} {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.

)}
)}
); }