mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 20:00:08 +00:00
refactor(settings): replace modal with nested full-page route
Settings sections are now URL-addressable at /settings/:sectionId, rendered nested inside EditorLayout alongside the stack sidebar. Browser back/forward navigates between sections. Deep links (e.g. /settings/cloud-backup) load the section directly on hard reload. - Add react-router-dom v7; BrowserRouter wraps the full app tree - New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink active styling, back-arrow), SectionGate (visibility + tier lock card) - Rename SectionId 'appstore' to 'app-store' so slug === SectionId - Decouple SystemSection, DeveloperSection, AppStoreSection from modal- passed props; each fetches its own data on mount - Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event - Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard, ConfigurationStatus; each calls useNavigate directly - Delete SettingsModal.tsx
This commit is contained in:
Generated
+58
@@ -46,6 +46,7 @@
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-is": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -4345,6 +4346,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cronstrue": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.14.0.tgz",
|
||||
@@ -6508,6 +6522,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
||||
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
|
||||
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.14.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
@@ -6734,6 +6786,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-is": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
+10
-7
@@ -1,3 +1,4 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { NodeProvider } from './context/NodeContext';
|
||||
import { LicenseProvider } from './context/LicenseContext';
|
||||
@@ -44,13 +45,15 @@ import { ToastContainer } from './components/ui/toast';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DeployFeedbackProvider>
|
||||
<AppContent />
|
||||
<DeployFeedbackPortal />
|
||||
</DeployFeedbackProvider>
|
||||
<ToastContainer />
|
||||
</AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<DeployFeedbackProvider>
|
||||
<AppContent />
|
||||
<DeployFeedbackPortal />
|
||||
</DeployFeedbackProvider>
|
||||
<ToastContainer />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import TerminalComponent from './Terminal';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import HomeDashboard from './HomeDashboard';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import type { SectionId } from './settings/types';
|
||||
import BashExecModal from './BashExecModal';
|
||||
import HostConsole from './HostConsole';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
@@ -37,7 +36,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { TopBar } from './TopBar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { Routes, Route, Navigate, useMatch, useNavigate } from 'react-router-dom';
|
||||
import { SettingsPage } from './settings/SettingsPage';
|
||||
import { StackAlertSheet } from './StackAlertSheet';
|
||||
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
|
||||
import { GitSourcePanel } from './stack/GitSourcePanel';
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
GlobalCommandPaletteTrigger,
|
||||
} from './GlobalCommandPalette';
|
||||
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
|
||||
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
||||
import { SENCHO_OPEN_LOGS_EVENT, SENCHO_LABELS_CHANGED } from '@/lib/events';
|
||||
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
@@ -359,11 +359,12 @@ export default function EditorLayout() {
|
||||
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
|
||||
const isAdmiral = license?.variant === 'admiral';
|
||||
|
||||
// Notifications & Settings state
|
||||
const navigate = useNavigate();
|
||||
const isSettingsRoute = !!useMatch({ path: '/settings/*', end: false });
|
||||
|
||||
// Notifications state
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [tickerConnected, setTickerConnected] = useState(false);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId>('account');
|
||||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
||||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
||||
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(null);
|
||||
@@ -573,6 +574,12 @@ export default function EditorLayout() {
|
||||
}
|
||||
}, [isPaid]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => refreshLabels();
|
||||
window.addEventListener(SENCHO_LABELS_CHANGED, handler);
|
||||
return () => window.removeEventListener(SENCHO_LABELS_CHANGED, handler);
|
||||
}, [refreshLabels]);
|
||||
|
||||
/**
|
||||
* Populate the per-stack "pending git source update" map. Runs on mount and
|
||||
* whenever a git-source change is signalled by the panel. Backend failure
|
||||
@@ -2040,7 +2047,7 @@ export default function EditorLayout() {
|
||||
toast.dismiss(loadingId);
|
||||
}
|
||||
},
|
||||
openLabelManager: () => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); },
|
||||
openLabelManager: () => navigate('/settings/labels'),
|
||||
openScheduleTask: () => {
|
||||
const stackName = file.replace(/\.(yml|yaml)$/, '');
|
||||
setSchedulePrefill({ stackName, nodeId: activeNode?.id ?? null });
|
||||
@@ -2266,7 +2273,7 @@ export default function EditorLayout() {
|
||||
isDarkMode={isDarkMode}
|
||||
nodeSwitcherSlot={
|
||||
<NodeSwitcher
|
||||
onManageNodes={() => { setSettingsInitialSection('nodes'); setSettingsModalOpen(true); }}
|
||||
onManageNodes={() => navigate('/settings/nodes')}
|
||||
/>
|
||||
}
|
||||
createStackSlot={createStackSlot}
|
||||
@@ -2328,12 +2335,19 @@ export default function EditorLayout() {
|
||||
<UserProfileDropdown
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
onOpenSettings={() => setSettingsModalOpen(true)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main Workspace */}
|
||||
{isSettingsRoute ? (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Routes>
|
||||
<Route path="/settings/:sectionId" element={<SettingsPage />} />
|
||||
<Route path="/settings" element={<Navigate to="/settings/account" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
) : (
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
||||
{activeView === 'templates' ? (
|
||||
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
|
||||
@@ -2917,10 +2931,10 @@ export default function EditorLayout() {
|
||||
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
||||
notifications={notifications}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
onOpenSettings={(section) => { setSettingsInitialSection(section); setSettingsModalOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
@@ -3055,14 +3069,6 @@ export default function EditorLayout() {
|
||||
)}
|
||||
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={settingsModalOpen}
|
||||
onClose={() => { setSettingsModalOpen(false); setSettingsInitialSection('account'); }}
|
||||
initialSection={settingsInitialSection}
|
||||
onLabelsChanged={refreshLabels}
|
||||
/>
|
||||
|
||||
{/* Stack Alert Sheet */}
|
||||
<StackAlertSheet
|
||||
isOpen={alertSheetOpen}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import type { SectionId } from './settings/types';
|
||||
import {
|
||||
HealthStatusBar,
|
||||
ResourceGauges,
|
||||
@@ -15,12 +14,11 @@ interface HomeDashboardProps {
|
||||
onNavigateToStack?: (stackFile: string) => void;
|
||||
notifications: NotificationItem[];
|
||||
onClearNotifications: () => void | Promise<void>;
|
||||
onOpenSettings?: (section: SectionId) => void;
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications, onOpenSettings }: HomeDashboardProps) {
|
||||
export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) {
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const data = useDashboardData();
|
||||
const activeNodeName = activeNode?.name || 'Local';
|
||||
@@ -52,7 +50,7 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ConfigurationStatus onOpenSettings={onOpenSettings} />
|
||||
<ConfigurationStatus />
|
||||
<RecentActivity />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import type { SenchoSettingsChangedDetail } from '@/lib/events';
|
||||
import { Search, X, Lock } from 'lucide-react';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { SSOSection } from './SSOSection';
|
||||
import { ApiTokensSection } from './ApiTokensSection';
|
||||
import { RegistriesSection } from './RegistriesSection';
|
||||
import {
|
||||
AccountSection,
|
||||
AppearanceSection,
|
||||
LicenseSection,
|
||||
UsersSection,
|
||||
SystemSection,
|
||||
NotificationsSection,
|
||||
NotificationRoutingSection,
|
||||
WebhooksSection,
|
||||
SecuritySection,
|
||||
CloudBackupSection,
|
||||
DeveloperSection,
|
||||
AppStoreSection,
|
||||
SupportSection,
|
||||
AboutSection,
|
||||
LabelsSection,
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS_GROUPS,
|
||||
SETTINGS_ITEMS,
|
||||
getSettingsItem,
|
||||
getSettingsGroup,
|
||||
isItemVisible,
|
||||
isItemLocked,
|
||||
} from './settings';
|
||||
import type {
|
||||
PatchableSettings,
|
||||
SectionId,
|
||||
SettingsItemMeta,
|
||||
VisibilityContext,
|
||||
} from './settings';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: SectionId;
|
||||
onLabelsChanged?: () => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged }: SettingsModalProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const { license, isPaid } = useLicense();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection || 'account');
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
|
||||
const visibility: VisibilityContext = useMemo(
|
||||
() => ({ isRemote, isAdmin, isPaid, isAdmiral }),
|
||||
[isRemote, isAdmin, isPaid, isAdmiral],
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(
|
||||
() => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)),
|
||||
[visibility],
|
||||
);
|
||||
|
||||
const visibleGroups = useMemo(() => {
|
||||
return SETTINGS_GROUPS
|
||||
.map(group => ({
|
||||
...group,
|
||||
items: visibleItems.filter(item => item.group === group.id),
|
||||
}))
|
||||
.filter(group => group.items.length > 0);
|
||||
}, [visibleItems]);
|
||||
|
||||
const contentViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
|
||||
|
||||
const switchSection = useCallback((next: SectionId) => {
|
||||
if (contentViewportRef.current) {
|
||||
scrollPositionsRef.current[activeSection] = contentViewportRef.current.scrollTop;
|
||||
}
|
||||
setActiveSection(next);
|
||||
}, [activeSection]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (contentViewportRef.current) {
|
||||
contentViewportRef.current.scrollTop = scrollPositionsRef.current[activeSection] ?? 0;
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialSection) setActiveSection(initialSection);
|
||||
}, [isOpen, initialSection]);
|
||||
|
||||
useEffect(() => {
|
||||
const current = getSettingsItem(activeSection);
|
||||
if (!current) return;
|
||||
if (!isItemVisible(current, visibility) && visibleItems.length > 0) {
|
||||
setActiveSection(visibleItems[0].id);
|
||||
}
|
||||
}, [visibility, visibleItems, activeSection]);
|
||||
|
||||
const handleDialogKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCommandOpen(open => !open);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
||||
const serverSettingsRef = useRef<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
||||
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
|
||||
const [isSavingSystem, setIsSavingSystem] = useState(false);
|
||||
const [isSavingDeveloper, setIsSavingDeveloper] = useState(false);
|
||||
|
||||
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.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
|
||||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
|
||||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days;
|
||||
|
||||
const sectionDirtyFlags: Partial<Record<SectionId, boolean>> = {
|
||||
system: hasSystemChanges,
|
||||
developer: hasDeveloperChanges,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchSettings();
|
||||
}, [isOpen, activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setIsSettingsLoading(true);
|
||||
try {
|
||||
const nodeRes = await apiFetch('/settings');
|
||||
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
|
||||
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
|
||||
const localData: Record<string, string> = (isRemote && localRes.ok)
|
||||
? await localRes.json()
|
||||
: nodeData;
|
||||
|
||||
const safe: PatchableSettings = {
|
||||
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 ?? '',
|
||||
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,
|
||||
audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_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, localOnly = false): Promise<boolean> => {
|
||||
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 () => {
|
||||
const payload = {
|
||||
developer_mode: settings.developer_mode,
|
||||
metrics_retention_hours: settings.metrics_retention_hours,
|
||||
log_retention_days: settings.log_retention_days,
|
||||
audit_retention_days: settings.audit_retention_days,
|
||||
};
|
||||
const ok = await patchSettings(payload, setIsSavingDeveloper, true);
|
||||
if (ok) {
|
||||
toast.success('Developer settings saved.');
|
||||
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
|
||||
detail: { changedKeys: Object.keys(payload) },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
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 < 8) {
|
||||
toast.error('New password must be at least 8 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 handleRegistrySaved = (key: keyof PatchableSettings, value: string) => {
|
||||
serverSettingsRef.current = { ...serverSettingsRef.current, [key]: value };
|
||||
};
|
||||
|
||||
const activeItem = getSettingsItem(activeSection);
|
||||
const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined;
|
||||
|
||||
const renderSection = () => {
|
||||
switch (activeSection) {
|
||||
case 'account':
|
||||
return (
|
||||
<AccountSection
|
||||
authData={authData}
|
||||
onAuthDataChange={setAuthData}
|
||||
onPasswordChange={handlePasswordChange}
|
||||
isSaving={isSavingPassword}
|
||||
/>
|
||||
);
|
||||
case 'appearance': return <AppearanceSection />;
|
||||
case 'license': return <LicenseSection />;
|
||||
case 'users': return <UsersSection />;
|
||||
case 'sso': return <SSOSection />;
|
||||
case 'api-tokens': return <ApiTokensSection />;
|
||||
case 'registries': return <RegistriesSection />;
|
||||
case 'labels': return <LabelsSection onLabelsChanged={onLabelsChanged} />;
|
||||
case 'system':
|
||||
return (
|
||||
<SystemSection
|
||||
settings={settings}
|
||||
onSettingChange={handleSettingChange}
|
||||
onSave={saveSystemSettings}
|
||||
isSaving={isSavingSystem}
|
||||
isLoading={isSettingsLoading}
|
||||
/>
|
||||
);
|
||||
case 'notifications': return <NotificationsSection />;
|
||||
case 'notification-routing': return <NotificationRoutingSection />;
|
||||
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
|
||||
case 'security': return <SecuritySection isPaid={isPaid} />;
|
||||
case 'cloud-backup': return <CloudBackupSection />;
|
||||
case 'developer':
|
||||
return (
|
||||
<DeveloperSection
|
||||
settings={settings}
|
||||
onSettingChange={handleSettingChange}
|
||||
onSave={saveDeveloperSettings}
|
||||
isSaving={isSavingDeveloper}
|
||||
isLoading={isSettingsLoading}
|
||||
/>
|
||||
);
|
||||
case 'nodes': return <NodeManager />;
|
||||
case 'appstore':
|
||||
return (
|
||||
<AppStoreSection
|
||||
settings={settings}
|
||||
onSettingChange={handleSettingChange}
|
||||
isLoading={isSettingsLoading}
|
||||
onSaved={handleRegistrySaved}
|
||||
/>
|
||||
);
|
||||
case 'support': return <SupportSection />;
|
||||
case 'about': return <AboutSection />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
showClose={false}
|
||||
onKeyDownCapture={handleDialogKeyDown}
|
||||
className="sm:max-w-[960px] h-[min(780px,90vh)] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0"
|
||||
>
|
||||
<VisuallyHidden><DialogTitle>Settings Hub</DialogTitle></VisuallyHidden>
|
||||
<VisuallyHidden><DialogDescription>Configure Sencho settings</DialogDescription></VisuallyHidden>
|
||||
|
||||
<aside className="w-[220px] bg-glass border-r border-glass-border flex flex-col shrink-0 min-h-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="font-display italic text-xl leading-none text-stat-value">Settings</div>
|
||||
<div className="mt-1 font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
{isRemote && activeNode ? `node · ${activeNode.name}` : 'control plane'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="mx-3 mb-3 flex items-center gap-2 rounded-md border border-card-border border-t-card-border-top bg-card px-2.5 py-1.5 shadow-card-bevel transition-colors hover:border-t-card-border-hover"
|
||||
>
|
||||
<Search className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />
|
||||
<span className="flex-1 text-left font-mono text-[11px] text-stat-subtitle">Filter settings</span>
|
||||
<kbd className="font-mono text-[10px] text-stat-subtitle/70 tabular-nums">{`\u2318K`}</kbd>
|
||||
</button>
|
||||
|
||||
<ScrollArea className="flex-1 px-3">
|
||||
<nav className="flex flex-col gap-4 pb-4">
|
||||
{visibleGroups.map(group => (
|
||||
<div key={group.id} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-baseline gap-1.5 px-2 pb-1 font-mono text-[9px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
<span>{group.label}</span>
|
||||
{group.kicker ? (
|
||||
<span className="text-stat-subtitle/60">· {group.kicker}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{group.items.map(item => {
|
||||
const locked = isItemLocked(item, visibility);
|
||||
const isActive = activeSection === item.id;
|
||||
const showDot = sectionDirtyFlags[item.id];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
onClick={() => switchSection(item.id)}
|
||||
className={cn(
|
||||
'relative flex items-center gap-2 rounded-sm px-2 py-[var(--density-cell-y)] text-left transition-colors',
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
|
||||
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<span className="absolute inset-y-1 left-0 w-[2px] rounded-full bg-brand" />
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'font-mono text-[11px] leading-none w-3 text-center',
|
||||
isActive ? 'text-brand' : 'text-stat-subtitle/70',
|
||||
)}
|
||||
>
|
||||
{group.glyph}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm font-medium">{item.label}</span>
|
||||
{item.tier ? (
|
||||
<TierChip tier={item.tier} locked={locked} />
|
||||
) : null}
|
||||
{showDot ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<header className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-border/60">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
<span>Settings</span>
|
||||
<span className="text-stat-subtitle/50">{`\u203A`}</span>
|
||||
<span>{activeGroup?.label ?? ''}</span>
|
||||
<span className="text-stat-subtitle/50">{`\u203A`}</span>
|
||||
<span className="text-stat-value">{activeItem?.label ?? ''}</span>
|
||||
{activeItem?.scope === 'node' ? (
|
||||
<span className="ml-2 flex items-center gap-1 text-brand">
|
||||
<span className="text-stat-subtitle/50">·</span>
|
||||
<span className="truncate max-w-[160px]">{activeNode?.name ?? 'local'}</span>
|
||||
<span className="text-stat-subtitle/70">(node-scoped)</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h2 className="mt-1.5 font-display italic text-2xl leading-tight text-stat-value truncate">
|
||||
{activeItem?.label ?? 'Settings'}
|
||||
</h2>
|
||||
{activeItem?.description ? (
|
||||
<div className="mt-1 text-sm text-stat-subtitle/90 truncate">
|
||||
{activeItem.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogClose className="mt-1 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</header>
|
||||
<ScrollArea block viewportRef={contentViewportRef} className="flex-1 min-w-0">
|
||||
<div className="px-6 py-5 flex flex-col gap-6 min-w-0">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
|
||||
<CommandInput placeholder="Jump to a setting..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No matching settings.</CommandEmpty>
|
||||
{visibleGroups.map(group => (
|
||||
<CommandGroup key={group.id} heading={group.label}>
|
||||
{group.items.map(item => (
|
||||
<CommandSearchItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
glyph={group.glyph}
|
||||
visibility={visibility}
|
||||
onSelect={() => {
|
||||
setCommandOpen(false);
|
||||
switchSection(item.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSearchItem({
|
||||
item,
|
||||
glyph,
|
||||
visibility,
|
||||
onSelect,
|
||||
}: {
|
||||
item: SettingsItemMeta;
|
||||
glyph: string;
|
||||
visibility: VisibilityContext;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const locked = isItemLocked(item, visibility);
|
||||
const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase();
|
||||
return (
|
||||
<CommandItem value={searchValue} onSelect={onSelect}>
|
||||
<span className="font-mono text-[11px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
|
||||
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
|
||||
</div>
|
||||
{item.tier ? <TierChip tier={item.tier} locked={locked} /> : null}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
function TierChip({ tier, locked }: { tier: NonNullable<SettingsItemMeta['tier']>; locked: boolean }) {
|
||||
const label = tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER';
|
||||
if (locked) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle/80">
|
||||
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.18em] text-brand/70">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Settings,
|
||||
LogOut,
|
||||
@@ -28,7 +29,6 @@ type Theme = 'light' | 'dark' | 'auto';
|
||||
interface UserProfileDropdownProps {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
@@ -48,7 +48,8 @@ function getInitials(username: string | undefined): string {
|
||||
return trimmed.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
|
||||
export function UserProfileDropdown({ theme, setTheme }: UserProfileDropdownProps) {
|
||||
const navigate = useNavigate();
|
||||
const { logout, user, isAdmin } = useAuth();
|
||||
const { license } = useLicense();
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
@@ -131,7 +132,7 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
|
||||
|
||||
{/* Navigation strip */}
|
||||
<div className="border-t border-card-border/60">
|
||||
<MenuRow icon={Settings} label="Settings" onClick={onOpenSettings} />
|
||||
<MenuRow icon={Settings} label="Settings" onClick={() => navigate('/settings')} />
|
||||
{showBilling ? (
|
||||
<MenuRow
|
||||
icon={CreditCard}
|
||||
|
||||
@@ -2,12 +2,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
|
||||
import { formatCount } from '@/lib/utils';
|
||||
import { useConfigurationStatus } from './useConfigurationStatus';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
|
||||
interface ConfigurationStatusProps {
|
||||
onOpenSettings?: (section: SectionId) => void;
|
||||
}
|
||||
|
||||
function StatusBadge({ value, locked, requiredTier }: {
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
@@ -94,10 +91,11 @@ function SkeletonRow() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigurationStatus({ onOpenSettings }: ConfigurationStatusProps) {
|
||||
export function ConfigurationStatus() {
|
||||
const navigate = useNavigate();
|
||||
const { status, loading } = useConfigurationStatus();
|
||||
|
||||
const open = (section: SectionId) => () => onOpenSettings?.(section);
|
||||
const open = (section: SectionId) => () => navigate(`/settings/${section}`);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -12,13 +12,6 @@ import { MfaBackupCodesDialog } from '@/components/mfa/MfaBackupCodesDialog';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
interface AccountSectionProps {
|
||||
authData: { oldPassword: string; newPassword: string; confirmPassword: string };
|
||||
onAuthDataChange: (data: { oldPassword: string; newPassword: string; confirmPassword: string }) => void;
|
||||
onPasswordChange: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
interface MfaStatus {
|
||||
enabled: boolean;
|
||||
backupCodesRemaining: number;
|
||||
@@ -30,7 +23,10 @@ interface SSOProvider {
|
||||
type: 'ldap' | 'oidc';
|
||||
}
|
||||
|
||||
export function AccountSection({ authData, onAuthDataChange, onPasswordChange, isSaving }: AccountSectionProps) {
|
||||
export function AccountSection() {
|
||||
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [mfa, setMfa] = useState<MfaStatus | null>(null);
|
||||
const [mfaLoading, setMfaLoading] = useState(true);
|
||||
const [hasSso, setHasSso] = useState(false);
|
||||
@@ -59,6 +55,39 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
|
||||
.catch(() => setHasSso(false));
|
||||
}, [refreshMfa]);
|
||||
|
||||
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 < 8) {
|
||||
toast.error('New password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
setIsSaving(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 {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBypassToggle = async (enforce: boolean) => {
|
||||
setTogglingBypass(true);
|
||||
try {
|
||||
@@ -89,7 +118,7 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
|
||||
<Input
|
||||
type="password"
|
||||
value={authData.oldPassword}
|
||||
onChange={(e) => onAuthDataChange({ ...authData, oldPassword: e.target.value })}
|
||||
onChange={(e) => setAuthData({ ...authData, oldPassword: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -97,7 +126,7 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
|
||||
<Input
|
||||
type="password"
|
||||
value={authData.newPassword}
|
||||
onChange={(e) => onAuthDataChange({ ...authData, newPassword: e.target.value })}
|
||||
onChange={(e) => setAuthData({ ...authData, newPassword: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -105,10 +134,10 @@ export function AccountSection({ authData, onAuthDataChange, onPasswordChange, i
|
||||
<Input
|
||||
type="password"
|
||||
value={authData.confirmPassword}
|
||||
onChange={(e) => onAuthDataChange({ ...authData, confirmPassword: e.target.value })}
|
||||
onChange={(e) => setAuthData({ ...authData, confirmPassword: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={onPasswordChange} disabled={isSaving} className="w-full">
|
||||
<Button onClick={handlePasswordChange} disabled={isSaving} className="w-full">
|
||||
{isSaving
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
|
||||
: 'Update Password'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -6,15 +6,6 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { PatchableSettings } from './types';
|
||||
|
||||
interface AppStoreSectionProps {
|
||||
settings: PatchableSettings;
|
||||
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
|
||||
isLoading: boolean;
|
||||
/** Called after a successful save so the parent can update serverSettingsRef */
|
||||
onSaved: (key: keyof PatchableSettings, value: string) => void;
|
||||
}
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return (
|
||||
@@ -30,22 +21,50 @@ function SettingsSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved }: AppStoreSectionProps) {
|
||||
export function AppStoreSection() {
|
||||
const [templateRegistryUrl, setTemplateRegistryUrl] = useState('');
|
||||
const serverUrl = useRef('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings');
|
||||
if (res.ok) {
|
||||
const data: Record<string, string> = await res.json();
|
||||
const url = data.template_registry_url ?? '';
|
||||
setTemplateRegistryUrl(url);
|
||||
serverUrl.current = url;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch app store settings', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const saveRegistrySettings = async () => {
|
||||
const trimmedUrl = templateRegistryUrl.trim();
|
||||
if (trimmedUrl && !/^https?:\/\/./.test(trimmedUrl)) {
|
||||
toast.error('Registry URL must start with http:// or https://');
|
||||
return;
|
||||
}
|
||||
setIsSavingRegistry(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }),
|
||||
body: JSON.stringify({ template_registry_url: trimmedUrl }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to save registry settings.');
|
||||
return;
|
||||
}
|
||||
onSaved('template_registry_url', settings.template_registry_url ?? '');
|
||||
serverUrl.current = templateRegistryUrl;
|
||||
await apiFetch('/templates/refresh-cache', { method: 'POST' });
|
||||
toast.success('Registry saved. App Store will reload from the new source.');
|
||||
} catch (e: unknown) {
|
||||
@@ -77,8 +96,8 @@ export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/templates.json"
|
||||
value={settings.template_registry_url ?? ''}
|
||||
onChange={(e) => onSettingChange('template_registry_url', e.target.value)}
|
||||
value={templateRegistryUrl}
|
||||
onChange={(e) => setTemplateRegistryUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the default LinuxServer.io registry.</p>
|
||||
</div>
|
||||
@@ -88,8 +107,8 @@ export function AppStoreSection({ settings, onSettingChange, isLoading, onSaved
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSettingChange('template_registry_url', '')}
|
||||
disabled={isSavingRegistry || !settings.template_registry_url}
|
||||
onClick={() => setTemplateRegistryUrl('')}
|
||||
disabled={isSavingRegistry || !templateRegistryUrl}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -5,14 +6,16 @@ import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { RefreshCw, Database } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
import type { SenchoSettingsChangedDetail } from '@/lib/events';
|
||||
import { DEFAULT_SETTINGS } from './types';
|
||||
import type { PatchableSettings } from './types';
|
||||
|
||||
interface DeveloperSectionProps {
|
||||
settings: PatchableSettings;
|
||||
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
|
||||
onSave: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
isLoading: boolean;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
function SettingsSkeleton() {
|
||||
@@ -29,8 +32,96 @@ function SettingsSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading }: DeveloperSectionProps) {
|
||||
type DeveloperFields = Pick<PatchableSettings, 'developer_mode' | 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days'>;
|
||||
|
||||
const DEFAULT_DEVELOPER: DeveloperFields = {
|
||||
developer_mode: DEFAULT_SETTINGS.developer_mode,
|
||||
metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours,
|
||||
log_retention_days: DEFAULT_SETTINGS.log_retention_days,
|
||||
audit_retention_days: DEFAULT_SETTINGS.audit_retention_days,
|
||||
};
|
||||
|
||||
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
const { isPaid, license } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const [settings, setSettings] = useState<DeveloperFields>({ ...DEFAULT_DEVELOPER });
|
||||
const serverSettingsRef = useRef<DeveloperFields>({ ...DEFAULT_DEVELOPER });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const hasChanges =
|
||||
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
|
||||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
|
||||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days ||
|
||||
settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const nodeRes = await apiFetch('/settings');
|
||||
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
|
||||
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
|
||||
const localData: Record<string, string> = (isRemote && localRes.ok)
|
||||
? await localRes.json()
|
||||
: nodeData;
|
||||
const safe: DeveloperFields = {
|
||||
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,
|
||||
audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch developer settings', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeNode?.id]);
|
||||
|
||||
const onSettingChange = <K extends keyof DeveloperFields>(key: K, value: DeveloperFields[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
const payload = {
|
||||
developer_mode: settings.developer_mode,
|
||||
metrics_retention_hours: settings.metrics_retention_hours,
|
||||
log_retention_days: settings.log_retention_days,
|
||||
audit_retention_days: settings.audit_retention_days,
|
||||
};
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
||||
return;
|
||||
}
|
||||
serverSettingsRef.current = { ...settings };
|
||||
toast.success('Developer settings saved.');
|
||||
window.dispatchEvent(new CustomEvent<SenchoSettingsChangedDetail>(SENCHO_SETTINGS_CHANGED, {
|
||||
detail: { changedKeys: Object.keys(payload) },
|
||||
}));
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -116,7 +207,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onSave} disabled={isSaving}>
|
||||
<Button onClick={saveSettings} disabled={isSaving}>
|
||||
{isSaving
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save Developer Settings'
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
|
||||
import { PaidGate } from '../PaidGate';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { LabelDot } from '../LabelPill';
|
||||
@@ -103,6 +104,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
setDialogOpen(false);
|
||||
fetchLabels();
|
||||
onLabelsChanged?.();
|
||||
window.dispatchEvent(new Event(SENCHO_LABELS_CHANGED));
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
@@ -122,6 +124,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
setDeleteTarget(null);
|
||||
fetchLabels();
|
||||
onLabelsChanged?.();
|
||||
window.dispatchEvent(new Event(SENCHO_LABELS_CHANGED));
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as Error)?.message || 'Something went wrong.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { SETTINGS_ITEMS, getSettingsItem, isItemVisible, isItemLocked } from './registry';
|
||||
import type { VisibilityContext } from './registry';
|
||||
import type { SectionId } from './types';
|
||||
|
||||
interface TierLockedCardProps {
|
||||
tier: 'skipper' | 'admiral';
|
||||
}
|
||||
|
||||
function TierLockedCard({ tier }: TierLockedCardProps) {
|
||||
const title = tier === 'admiral' ? 'Admiral feature' : 'Skipper feature';
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-glass-border bg-glass px-10 py-8 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-glass-border bg-glass">
|
||||
<Lock className="h-5 w-5 text-stat-subtitle" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold text-stat-value">{title}</p>
|
||||
<p className="text-sm text-stat-subtitle">Upgrade to unlock more features.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionGateProps {
|
||||
sectionId: SectionId;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SectionGate({ sectionId, children }: SectionGateProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const visibility: VisibilityContext = {
|
||||
isAdmin,
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
isRemote,
|
||||
};
|
||||
|
||||
const item = getSettingsItem(sectionId);
|
||||
|
||||
if (!item || !isItemVisible(item, visibility)) {
|
||||
const fallback = SETTINGS_ITEMS.find(i => isItemVisible(i, visibility));
|
||||
return <Navigate to={`/settings/${fallback?.id ?? 'appearance'}`} replace />;
|
||||
}
|
||||
|
||||
if (isItemLocked(item, visibility) && (item.tier === 'skipper' || item.tier === 'admiral')) {
|
||||
return <TierLockedCard tier={item.tier} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { NodeManager } from '../NodeManager';
|
||||
import { SSOSection } from '../SSOSection';
|
||||
import { ApiTokensSection } from '../ApiTokensSection';
|
||||
import { RegistriesSection } from '../RegistriesSection';
|
||||
import {
|
||||
AccountSection,
|
||||
AppearanceSection,
|
||||
LicenseSection,
|
||||
UsersSection,
|
||||
SystemSection,
|
||||
NotificationsSection,
|
||||
NotificationRoutingSection,
|
||||
WebhooksSection,
|
||||
SecuritySection,
|
||||
CloudBackupSection,
|
||||
DeveloperSection,
|
||||
AppStoreSection,
|
||||
SupportSection,
|
||||
AboutSection,
|
||||
LabelsSection,
|
||||
SETTINGS_ITEMS,
|
||||
SETTINGS_GROUPS,
|
||||
getSettingsItem,
|
||||
getSettingsGroup,
|
||||
isItemVisible,
|
||||
isItemLocked,
|
||||
} from './index';
|
||||
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
|
||||
import { SectionGate } from './SectionGate';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { sectionId } = useParams<{ sectionId: string }>();
|
||||
const currentSection = (sectionId ?? 'account') as SectionId;
|
||||
|
||||
const contentViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollPositionsRef = useRef<Partial<Record<SectionId, number>>>({});
|
||||
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [dirtyFlags, setDirtyFlags] = useState<Partial<Record<SectionId, boolean>>>({});
|
||||
|
||||
const handleDirtyChange = useCallback((section: SectionId, dirty: boolean) => {
|
||||
setDirtyFlags(prev => {
|
||||
if (prev[section] === dirty) return prev;
|
||||
return { ...prev, [section]: dirty };
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (contentViewportRef.current) {
|
||||
contentViewportRef.current.scrollTop = scrollPositionsRef.current[currentSection] ?? 0;
|
||||
}
|
||||
}, [currentSection]);
|
||||
|
||||
const saveScrollPosition = useCallback(() => {
|
||||
if (contentViewportRef.current) {
|
||||
scrollPositionsRef.current[currentSection] = contentViewportRef.current.scrollTop;
|
||||
}
|
||||
}, [currentSection]);
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const visibility: VisibilityContext = useMemo(
|
||||
() => ({ isRemote, isAdmin, isPaid, isAdmiral }),
|
||||
[isRemote, isAdmin, isPaid, isAdmiral],
|
||||
);
|
||||
|
||||
const activeItem = getSettingsItem(currentSection);
|
||||
const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined;
|
||||
|
||||
const visibleItems = useMemo(
|
||||
() => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)),
|
||||
[visibility],
|
||||
);
|
||||
|
||||
const visibleGroups = useMemo(() =>
|
||||
SETTINGS_GROUPS
|
||||
.map(group => ({
|
||||
...group,
|
||||
items: visibleItems.filter(item => item.group === group.id),
|
||||
}))
|
||||
.filter(group => group.items.length > 0),
|
||||
[visibleItems],
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setCommandOpen(open => !open);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sectionElement = useMemo(() => {
|
||||
switch (currentSection) {
|
||||
case 'account': return <AccountSection />;
|
||||
case 'appearance': return <AppearanceSection />;
|
||||
case 'license': return <LicenseSection />;
|
||||
case 'users': return <UsersSection />;
|
||||
case 'sso': return <SSOSection />;
|
||||
case 'api-tokens': return <ApiTokensSection />;
|
||||
case 'registries': return <RegistriesSection />;
|
||||
case 'labels': return <LabelsSection />;
|
||||
case 'system': return <SystemSection onDirtyChange={(d) => handleDirtyChange('system', d)} />;
|
||||
case 'notifications': return <NotificationsSection />;
|
||||
case 'notification-routing': return <NotificationRoutingSection />;
|
||||
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
|
||||
case 'security': return <SecuritySection isPaid={isPaid} />;
|
||||
case 'cloud-backup': return <CloudBackupSection />;
|
||||
case 'developer': return <DeveloperSection onDirtyChange={(d) => handleDirtyChange('developer', d)} />;
|
||||
case 'nodes': return <NodeManager />;
|
||||
case 'app-store': return <AppStoreSection />;
|
||||
case 'support': return <SupportSection />;
|
||||
case 'about': return <AboutSection />;
|
||||
default: return <Navigate to="/settings/appearance" replace />;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSection, isPaid]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 overflow-hidden min-h-0"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<SettingsSidebar
|
||||
dirtyFlags={dirtyFlags}
|
||||
onOpenPalette={() => setCommandOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<header className="flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-border/60 shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
<span>Settings</span>
|
||||
<span className="text-stat-subtitle/50">›</span>
|
||||
<span>{activeGroup?.label ?? ''}</span>
|
||||
<span className="text-stat-subtitle/50">›</span>
|
||||
<span className="text-stat-value">{activeItem?.label ?? ''}</span>
|
||||
{activeItem?.scope === 'node' ? (
|
||||
<span className="ml-2 flex items-center gap-1 text-brand">
|
||||
<span className="text-stat-subtitle/50">·</span>
|
||||
<span className="truncate max-w-[160px]">{activeNode?.name ?? 'local'}</span>
|
||||
<span className="text-stat-subtitle/70">(node-scoped)</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h2 className="mt-1.5 font-display italic text-2xl leading-tight text-stat-value truncate">
|
||||
{activeItem?.label ?? 'Settings'}
|
||||
</h2>
|
||||
{activeItem?.description ? (
|
||||
<p className="mt-1 text-sm text-stat-subtitle/90 truncate">
|
||||
{activeItem.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ScrollArea
|
||||
block
|
||||
viewportRef={contentViewportRef}
|
||||
className="flex-1 min-w-0"
|
||||
onScrollCapture={saveScrollPosition}
|
||||
>
|
||||
<div className="px-6 py-5 flex flex-col gap-6 min-w-0">
|
||||
<SectionGate sectionId={currentSection}>
|
||||
{sectionElement}
|
||||
</SectionGate>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
|
||||
<CommandInput placeholder="Jump to a setting..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No matching settings.</CommandEmpty>
|
||||
{visibleGroups.map(group => (
|
||||
<CommandGroup key={group.id} heading={group.label}>
|
||||
{group.items.map(item => (
|
||||
<SettingsCommandItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
glyph={group.glyph}
|
||||
visibility={visibility}
|
||||
onSelect={() => {
|
||||
setCommandOpen(false);
|
||||
navigate(`/settings/${item.id}`);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsCommandItem({
|
||||
item,
|
||||
glyph,
|
||||
visibility,
|
||||
onSelect,
|
||||
}: {
|
||||
item: SettingsItemMeta;
|
||||
glyph: string;
|
||||
visibility: VisibilityContext;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const locked = isItemLocked(item, visibility);
|
||||
const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase();
|
||||
return (
|
||||
<CommandItem value={searchValue} onSelect={onSelect}>
|
||||
<span className="font-mono text-[11px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
|
||||
<div className={cn('flex flex-col gap-0.5 min-w-0 flex-1', locked && 'opacity-60')}>
|
||||
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
|
||||
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
|
||||
</div>
|
||||
{item.tier && locked ? (
|
||||
<span className="flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle/80">
|
||||
<Lock className="h-2.5 w-2.5" strokeWidth={1.5} />
|
||||
{item.tier === 'admiral' ? 'ADMIRAL' : 'SKIPPER'}
|
||||
</span>
|
||||
) : null}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Lock, Search } from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry';
|
||||
import type { VisibilityContext, SettingsItemMeta } from './registry';
|
||||
import type { SectionId } from './types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TierChipProps {
|
||||
tier: 'skipper' | 'admiral';
|
||||
}
|
||||
|
||||
function TierChip({ tier }: TierChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
tier === 'admiral'
|
||||
? 'bg-warning/15 text-warning'
|
||||
: 'bg-brand/15 text-brand',
|
||||
)}
|
||||
>
|
||||
{tier === 'admiral' ? 'Admiral' : 'Skipper'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
dirtyFlags?: Partial<Record<SectionId, boolean>>;
|
||||
onOpenPalette: () => void;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({ dirtyFlags, onOpenPalette }: SettingsSidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const visibility: VisibilityContext = {
|
||||
isAdmin,
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
isRemote,
|
||||
};
|
||||
|
||||
const nodeName = activeNode?.name ?? 'control plane';
|
||||
|
||||
function handleBack() {
|
||||
if (window.history.length <= 1) {
|
||||
navigate('/');
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function isVisible(item: SettingsItemMeta): boolean {
|
||||
return isItemVisible(item, visibility);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-[220px] bg-glass border-r border-glass-border flex flex-col shrink-0 min-h-0">
|
||||
<div className="flex items-center gap-2 px-4 pt-5 pb-3">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-stat-subtitle transition-colors hover:bg-accent/40 hover:text-stat-value"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-display italic text-xl leading-tight text-stat-value">Settings</p>
|
||||
<p className="truncate text-[11px] text-stat-subtitle">{nodeName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<button
|
||||
onClick={onOpenPalette}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-glass-border bg-glass px-3 py-1.5 text-xs text-stat-subtitle transition-colors hover:border-brand/30 hover:text-stat-value"
|
||||
>
|
||||
<Search className="h-3 w-3 shrink-0" />
|
||||
<span className="flex-1 text-left">Filter settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-3">
|
||||
<nav className="pb-4">
|
||||
{SETTINGS_GROUPS.map(group => {
|
||||
const items = SETTINGS_ITEMS.filter(
|
||||
item => item.group === group.id && isVisible(item),
|
||||
);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.id} className="mb-1 mt-3">
|
||||
<p className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-stat-subtitle/60">
|
||||
{group.label}
|
||||
</p>
|
||||
{items.map(item => {
|
||||
const locked = isItemLocked(item, visibility);
|
||||
const isDirty = dirtyFlags?.[item.id] ?? false;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.id}
|
||||
to={`/settings/${item.id}`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand/10 to-transparent text-stat-value'
|
||||
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute inset-y-1 left-0 w-[2px] rounded-full bg-brand" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{isDirty && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
|
||||
)}
|
||||
{locked && <Lock className="h-3 w-3 shrink-0 text-stat-subtitle/60" />}
|
||||
{item.tier && !locked && (
|
||||
<TierChip tier={item.tier} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,14 @@ import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { DEFAULT_SETTINGS } from './types';
|
||||
import type { PatchableSettings } from './types';
|
||||
|
||||
interface SystemSectionProps {
|
||||
settings: PatchableSettings;
|
||||
onSettingChange: <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => void;
|
||||
onSave: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
isLoading: boolean;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
interface NumberChipProps {
|
||||
@@ -153,7 +153,84 @@ function SettingsSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemSection({ settings, onSettingChange, onSave, isSaving, isLoading }: SystemSectionProps) {
|
||||
type SystemFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'docker_janitor_gb' | 'global_crash'>;
|
||||
|
||||
const DEFAULT_SYSTEM: SystemFields = {
|
||||
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
|
||||
host_ram_limit: DEFAULT_SETTINGS.host_ram_limit,
|
||||
host_disk_limit: DEFAULT_SETTINGS.host_disk_limit,
|
||||
docker_janitor_gb: DEFAULT_SETTINGS.docker_janitor_gb,
|
||||
global_crash: DEFAULT_SETTINGS.global_crash,
|
||||
};
|
||||
|
||||
export function SystemSection({ onDirtyChange }: SystemSectionProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const [settings, setSettings] = useState<SystemFields>({ ...DEFAULT_SYSTEM });
|
||||
const serverSettingsRef = useRef<SystemFields>({ ...DEFAULT_SYSTEM });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const hasChanges =
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nodeRes = await apiFetch('/settings');
|
||||
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
|
||||
const safe: SystemFields = {
|
||||
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,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch system settings', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSettings();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeNode?.id]);
|
||||
|
||||
const onSettingChange = <K extends keyof SystemFields>(key: K, value: SystemFields[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
||||
return;
|
||||
}
|
||||
serverSettingsRef.current = { ...settings };
|
||||
toast.success('System limits saved.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <SettingsSkeleton />;
|
||||
|
||||
return (
|
||||
@@ -229,7 +306,7 @@ export function SystemSection({ settings, onSettingChange, onSave, isSaving, isL
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onSave} disabled={isSaving}>
|
||||
<Button onClick={saveSettings} disabled={isSaving}>
|
||||
{isSaving
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save limits'
|
||||
|
||||
@@ -194,7 +194,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
scope: 'node',
|
||||
},
|
||||
{
|
||||
id: 'appstore',
|
||||
id: 'app-store',
|
||||
group: 'advanced',
|
||||
label: 'App Store',
|
||||
description: 'Template registry URL and featured-catalog source.',
|
||||
|
||||
@@ -40,7 +40,7 @@ export type SectionId =
|
||||
| 'cloud-backup'
|
||||
| 'developer'
|
||||
| 'nodes'
|
||||
| 'appstore'
|
||||
| 'app-store'
|
||||
| 'notification-routing'
|
||||
| 'support'
|
||||
| 'about';
|
||||
|
||||
@@ -12,3 +12,5 @@ export const SENCHO_SETTINGS_CHANGED = 'sencho-settings-changed';
|
||||
export interface SenchoSettingsChangedDetail {
|
||||
changedKeys: string[];
|
||||
}
|
||||
|
||||
export const SENCHO_LABELS_CHANGED = 'sencho-labels-changed';
|
||||
|
||||
Reference in New Issue
Block a user