mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
feat(settings): harden settings API and overhaul SettingsModal
Security: - Strip auth credential keys (auth_username, auth_password_hash, auth_jwt_secret) from GET /api/settings response - Add allowlist guard to POST /api/settings — rejects unknown or auth-namespace keys with a 400 Backend: - Add PATCH /api/settings bulk endpoint with Zod schema validation (type coercion, range checks, URL format) and atomic SQLite transaction - Add system_state table — moves last_janitor_alert_timestamp out of global_settings; adds getSystemState/setSystemState on DatabaseService - Add metrics_retention_hours and log_retention_days configurable settings; MonitorService reads both dynamically each evaluation cycle - Add cleanupOldNotifications(days) to DatabaseService, called each cycle Frontend: - Replace single isLoading flag with per-operation states (isSavingSystem, isSavingDeveloper, isSavingPassword, isSavingRegistry, isSavingAgent/isTestingAgent per agent type) - Add skeleton loader that blocks interaction until fetchSettings resolves - Explicit key-picking in fetchSettings — auth keys cannot enter state - Unsaved-changes amber dot on System Limits and Developer sidebar items - Separate saveSystemSettings / saveDeveloperSettings — no cross-tab clobber - Developer tab gains Data Retention section (metrics hours, log days) - All settings saves use new PATCH /api/settings endpoint
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,10 +9,12 @@ 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 } from 'lucide-react';
|
||||
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
@@ -22,6 +24,22 @@ interface Agent {
|
||||
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;
|
||||
@@ -29,10 +47,23 @@ interface SettingsModalProps {
|
||||
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' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'>('account');
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('account');
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
useEffect(() => {
|
||||
@@ -44,45 +75,59 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
// Auth State
|
||||
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
|
||||
// Notifications State
|
||||
// Notification agents state
|
||||
const [agents, setAgents] = useState<Record<string, Agent>>({
|
||||
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<Record<string, string>>({
|
||||
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'
|
||||
});
|
||||
// Settings state — all user-configurable keys (no auth keys)
|
||||
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [registryUrl, setRegistryUrl] = useState('');
|
||||
// Track server state to detect unsaved changes without causing re-renders
|
||||
const serverSettingsRef = useRef<PatchableSettings>({ ...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<Record<string, boolean>>({});
|
||||
const [isTestingAgent, setIsTestingAgent] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 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]);
|
||||
}, [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();
|
||||
const newAgents = { ...agents };
|
||||
data.forEach(a => {
|
||||
newAgents[a.type] = a;
|
||||
setAgents(prev => {
|
||||
const next = { ...prev };
|
||||
data.forEach(a => { next[a.type] = a; });
|
||||
return next;
|
||||
});
|
||||
setAgents(newAgents);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch agents', e);
|
||||
@@ -90,64 +135,127 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setIsSettingsLoading(true);
|
||||
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);
|
||||
}
|
||||
const data: Record<string, string> = await res.json();
|
||||
// Explicitly pick only known patchable keys — never allow auth keys into component state
|
||||
const safe: PatchableSettings = {
|
||||
host_cpu_limit: data.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
|
||||
host_ram_limit: data.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
|
||||
host_disk_limit: data.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
||||
docker_janitor_gb: data.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
|
||||
global_crash: (data.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
global_logs_refresh: (data.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
|
||||
developer_mode: (data.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||
template_registry_url: data.template_registry_url ?? '',
|
||||
metrics_retention_hours: data.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
||||
log_retention_days: data.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 = <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
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 () => {
|
||||
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);
|
||||
if (ok) toast.success('Developer settings saved.');
|
||||
};
|
||||
|
||||
const saveRegistrySettings = async () => {
|
||||
setIsSavingRegistry(true);
|
||||
try {
|
||||
await apiFetch('/settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'template_registry_url', value: registryUrl.trim() })
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }),
|
||||
});
|
||||
// Bust the template cache so the next App Store load uses the new 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) {
|
||||
toast.error('Failed to save registry settings.');
|
||||
} 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: any) => {
|
||||
const handleAgentChange = (type: string, field: keyof Agent, value: Agent[keyof Agent]) => {
|
||||
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);
|
||||
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 successfully.`);
|
||||
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved.`);
|
||||
} else {
|
||||
toast.error(`Failed to save ${type} settings.`);
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Something went wrong.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSavingAgent(prev => ({ ...prev, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +264,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
toast.error('Please enter a webhook URL first.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setIsTestingAgent(prev => ({ ...prev, [type]: true }));
|
||||
try {
|
||||
const res = await apiFetch('/notifications/test', {
|
||||
method: 'POST',
|
||||
@@ -165,64 +273,46 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
if (res.ok) {
|
||||
toast.success('Test notification sent!');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.details || 'Test failed.');
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.details || err?.error || 'Test failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || '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);
|
||||
setIsTestingAgent(prev => ({ ...prev, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) {
|
||||
toast.error("All fields are required");
|
||||
toast.error('All fields are required');
|
||||
return;
|
||||
}
|
||||
if (authData.newPassword !== authData.confirmPassword) {
|
||||
toast.error("New passwords do not match");
|
||||
toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
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
|
||||
})
|
||||
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');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to update password');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error during password change');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error during password change');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,94 +336,87 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2 justify-end pt-4">
|
||||
<Button variant="outline" onClick={() => testAgent(type)} disabled={isLoading}>Test</Button>
|
||||
<Button onClick={() => saveAgent(type)} disabled={isLoading}>Save</Button>
|
||||
<Button variant="outline" onClick={() => testAgent(type)} disabled={isTestingAgent[type]}>
|
||||
{isTestingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Testing...</> : 'Test'}
|
||||
</Button>
|
||||
<Button onClick={() => saveAgent(type)} disabled={isSavingAgent[type]}>
|
||||
{isSavingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsSkeleton = () => (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NavButton = ({ section, icon, label, showDot }: { section: SectionId; icon: React.ReactNode; label: string; showDot?: boolean }) => (
|
||||
<Button
|
||||
variant={activeSection === section ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium relative"
|
||||
onClick={() => setActiveSection(section)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{showDot && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[900px] h-[650px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
|
||||
{/* Sidebar */}
|
||||
<div className="w-[200px] bg-muted/20 border-r border-border flex flex-col p-4 shrink-0">
|
||||
<div className="font-semibold text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
|
||||
{isRemote && (
|
||||
{isRemote ? (
|
||||
<div className="text-xs text-muted-foreground mb-5 truncate">{activeNode!.name}</div>
|
||||
) : (
|
||||
<div className="mb-5" />
|
||||
)}
|
||||
{!isRemote && <div className="mb-5" />}
|
||||
<nav className="space-y-1.5 flex flex-col">
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('account')}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Account
|
||||
</Button>
|
||||
<NavButton section="account" icon={<Shield className="w-4 h-4 mr-2" />} label="Account" />
|
||||
)}
|
||||
<Button
|
||||
variant={activeSection === 'system' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('system')}
|
||||
>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
System Limits
|
||||
</Button>
|
||||
<NavButton
|
||||
section="system"
|
||||
icon={<Activity className="w-4 h-4 mr-2" />}
|
||||
label="System Limits"
|
||||
showDot={hasSystemChanges}
|
||||
/>
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('notifications')}
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notifications
|
||||
</Button>
|
||||
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('appearance')}
|
||||
>
|
||||
<Palette className="w-4 h-4 mr-2" />
|
||||
Appearance
|
||||
</Button>
|
||||
<NavButton section="appearance" icon={<Palette className="w-4 h-4 mr-2" />} label="Appearance" />
|
||||
)}
|
||||
<Button
|
||||
variant={activeSection === 'developer' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('developer')}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
Developer
|
||||
</Button>
|
||||
<NavButton
|
||||
section="developer"
|
||||
icon={<Code className="w-4 h-4 mr-2" />}
|
||||
label="Developer"
|
||||
showDot={hasDeveloperChanges}
|
||||
/>
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'nodes' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('nodes')}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
Nodes
|
||||
</Button>
|
||||
<NavButton section="nodes" icon={<Server className="w-4 h-4 mr-2" />} label="Nodes" />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'appstore' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('appstore')}
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
App Store
|
||||
</Button>
|
||||
<NavButton section="appstore" icon={<Package className="w-4 h-4 mr-2" />} label="App Store" />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
|
||||
{activeSection === 'account' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -365,8 +448,11 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
onChange={(e) => setAuthData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handlePasswordChange} disabled={isLoading} className="w-full">
|
||||
Update Password
|
||||
<Button onClick={handlePasswordChange} disabled={isSavingPassword} className="w-full">
|
||||
{isSavingPassword
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
|
||||
: 'Update Password'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,74 +460,97 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
{activeSection === 'system' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">System Limits & Watchdog</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure auto-recovery thresholds and server constraints.</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">System Limits & Watchdog</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure alert thresholds and crash detection.</p>
|
||||
</div>
|
||||
{isRemote && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
|
||||
<Info className="w-3 h-3 mr-1" />
|
||||
{activeNode!.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host CPU Limit</Label>
|
||||
<span className="text-sm font-medium">{settings.host_cpu_limit}%</span>
|
||||
{isSettingsLoading ? <SettingsSkeleton /> : (
|
||||
<>
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host CPU Alert Threshold</Label>
|
||||
<span className="text-sm font-medium">{settings.host_cpu_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1} max={100} step={1}
|
||||
value={[parseInt(settings.host_cpu_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_cpu_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host RAM Alert Threshold</Label>
|
||||
<span className="text-sm font-medium">{settings.host_ram_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1} max={100} step={1}
|
||||
value={[parseInt(settings.host_ram_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_ram_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host Disk Alert Threshold</Label>
|
||||
<span className="text-sm font-medium">{settings.host_disk_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1} max={100} step={1}
|
||||
value={[parseInt(settings.host_disk_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_disk_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label className="text-base">Docker Janitor Storage Threshold</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={settings.docker_janitor_gb}
|
||||
onChange={(e) => handleSettingChange('docker_janitor_gb', e.target.value)}
|
||||
className="max-w-[150px]"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">GB reclaimable</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Alert when unused Docker data exceeds this size.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="global_crash" className="text-base">Global Crash Detection</Label>
|
||||
<p className="text-xs text-muted-foreground">Watch all containers for unexpected exits</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="global_crash"
|
||||
checked={settings.global_crash === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('global_crash', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_cpu_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_cpu_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host RAM Limit</Label>
|
||||
<span className="text-sm font-medium">{settings.host_ram_limit}%</span>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={saveSystemSettings} disabled={isSavingSystem}>
|
||||
{isSavingSystem
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save Limits'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_ram_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_ram_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host Disk Limit</Label>
|
||||
<span className="text-sm font-medium">{settings.host_disk_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_disk_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_disk_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label className="text-base">Docker Janitor Storage Threshold (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.docker_janitor_gb}
|
||||
onChange={(e) => handleSettingChange('docker_janitor_gb', e.target.value)}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="global_crash" className="text-base">Global Crash Detection</Label>
|
||||
<p className="text-xs text-muted-foreground">Watch all containers indefinitely</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="global_crash"
|
||||
checked={settings.global_crash === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('global_crash', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={saveSettings} disabled={isLoading}>Save Limits</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -470,7 +579,6 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<h3 className="text-lg font-semibold tracking-tight">Appearance</h3>
|
||||
<p className="text-sm text-muted-foreground">Customize Sencho's visual theme.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mt-6">
|
||||
<Button
|
||||
variant={!isDarkMode ? 'default' : 'outline'}
|
||||
@@ -494,50 +602,114 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
{activeSection === 'developer' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Developer</h3>
|
||||
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and extended diagnostics.</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Developer</h3>
|
||||
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and data retention.</p>
|
||||
</div>
|
||||
{isRemote && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
|
||||
<Info className="w-3 h-3 mr-1" />
|
||||
{activeNode!.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
|
||||
{isSettingsLoading ? <SettingsSkeleton /> : (
|
||||
<>
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="developer_mode"
|
||||
checked={settings.developer_mode === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('developer_mode', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4 border-t border-border">
|
||||
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>
|
||||
Standard Log Polling Rate
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.global_logs_refresh}
|
||||
onValueChange={(val) => handleSettingChange('global_logs_refresh', val as '1' | '3' | '5' | '10')}
|
||||
disabled={settings.developer_mode === '1'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[200px]">
|
||||
<SelectValue placeholder="Select rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 second</SelectItem>
|
||||
<SelectItem value="3">3 seconds</SelectItem>
|
||||
<SelectItem value="5">5 seconds</SelectItem>
|
||||
<SelectItem value="10">10 seconds</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.developer_mode === '1' && (
|
||||
<p className="text-xs text-amber-500">SSE streaming is active — polling rate is overridden.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="developer_mode"
|
||||
checked={settings.developer_mode === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('developer_mode', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4 border-t border-border">
|
||||
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>Standard Log Polling Rate</Label>
|
||||
<Select
|
||||
value={settings.global_logs_refresh}
|
||||
onValueChange={(val) => handleSettingChange('global_logs_refresh', val)}
|
||||
disabled={settings.developer_mode === '1'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[200px]">
|
||||
<SelectValue placeholder="Select rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 second</SelectItem>
|
||||
<SelectItem value="3">3 seconds</SelectItem>
|
||||
<SelectItem value="5">5 seconds</SelectItem>
|
||||
<SelectItem value="10">10 seconds</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.developer_mode === '1' && (
|
||||
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden by real-time streaming.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Data Retention (Observability) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">Data Retention</span>
|
||||
</div>
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">Container Metrics Retention</Label>
|
||||
<p className="text-xs text-muted-foreground">How long to keep per-container CPU/RAM/network history.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={8760}
|
||||
value={settings.metrics_retention_hours}
|
||||
onChange={(e) => handleSettingChange('metrics_retention_hours', e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-8">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={saveSettings} disabled={isLoading}>Save Developer Settings</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 pt-4 border-t border-border">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">Notification Log Retention</Label>
|
||||
<p className="text-xs text-muted-foreground">How long to keep alert and notification history.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.log_retention_days}
|
||||
onChange={(e) => handleSettingChange('log_retention_days', e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-8">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={saveDeveloperSettings} disabled={isSavingDeveloper}>
|
||||
{isSavingDeveloper
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save Developer Settings'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -552,52 +724,51 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<p className="text-sm text-muted-foreground">Configure the template source used by the App Store.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Default Registry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
LinuxServer.io — <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
|
||||
</div>
|
||||
</div>
|
||||
{isSettingsLoading ? <SettingsSkeleton /> : (
|
||||
<>
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Default Registry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
LinuxServer.io — <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-border">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Custom Registry URL</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
|
||||
</p>
|
||||
<div className="space-y-3 pt-4 border-t border-border">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Custom Registry URL</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/templates.json"
|
||||
value={settings.template_registry_url ?? ''}
|
||||
onChange={(e) => handleSettingChange('template_registry_url', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the default LinuxServer.io registry.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/templates.json"
|
||||
value={registryUrl}
|
||||
onChange={(e) => setRegistryUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default LinuxServer.io registry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRegistryUrl('')}
|
||||
disabled={isSavingRegistry || registryUrl === ''}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
|
||||
{isSavingRegistry ? (
|
||||
<><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
) : (
|
||||
'Save & Refresh'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSettingChange('template_registry_url', '')}
|
||||
disabled={isSavingRegistry || !settings.template_registry_url}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
|
||||
{isSavingRegistry
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save & Refresh'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user