diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6c5d311e..fe690700 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ const Dashboard = lazy(() => import('./pages/Dashboard')) const ProxyHosts = lazy(() => import('./pages/ProxyHosts')) const RemoteServers = lazy(() => import('./pages/RemoteServers')) const ImportCaddy = lazy(() => import('./pages/ImportCaddy')) +const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec')) const Certificates = lazy(() => import('./pages/Certificates')) const SystemSettings = lazy(() => import('./pages/SystemSettings')) const Account = lazy(() => import('./pages/Account')) @@ -67,6 +68,10 @@ export default function App() { } /> } /> } /> + + } /> + } /> + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4d34a865..2f05ff4e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -50,7 +50,7 @@ export default function Layout({ children }: LayoutProps) { { name: 'Security', path: '/security', icon: '🛡️' }, { name: 'Uptime', path: '/uptime', icon: '📈' }, { name: 'Notifications', path: '/notifications', icon: '🔔' }, - { name: 'Import Caddyfile', path: '/import', icon: '📥' }, + // Import group moved under Tasks { name: 'Settings', path: '/settings', @@ -65,6 +65,15 @@ export default function Layout({ children }: LayoutProps) { path: '/tasks', icon: '📋', children: [ + { + name: 'Import', + path: '/tasks/import', + icon: '📥', + children: [ + { name: 'Caddyfile', path: '/tasks/import/caddyfile', icon: '📥' }, + { name: 'CrowdSec', path: '/tasks/import/crowdsec', icon: '🛡️' }, + ] + }, { name: 'Backups', path: '/tasks/backups', icon: '💾' }, { name: 'Logs', path: '/tasks/logs', icon: '📝' }, ] @@ -154,6 +163,52 @@ export default function Layout({ children }: LayoutProps) { {isExpanded && (
{item.children.map((child) => { + // If this child has its own children, render a nested accordion + if ((child as any).children) { + + const nestedExpandedKey = `${item.name}:${child.name}` + const isNestedOpen = expandedMenus.includes(nestedExpandedKey) + return ( +
+ + {isNestedOpen && ( +
+ {(child as any).children.map((sub: any) => ( + setMobileSidebarOpen(false)} + className={`block py-2 px-3 rounded-md text-sm transition-colors ${ + location.pathname === sub.path + ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300' + : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800/50' + }`} + > + {sub.name} + + ))} +
+ )} +
+ ) + } const isChildActive = location.pathname === child.path return ( { expect(logos[0]).toBeInTheDocument() }) - it('renders all navigation items', () => { + it('renders all navigation items', async () => { renderWithProviders(
Test Content
@@ -68,7 +68,12 @@ describe('Layout', () => { expect(screen.getByText('Proxy Hosts')).toBeInTheDocument() expect(screen.getByText('Remote Servers')).toBeInTheDocument() expect(screen.getByText('Certificates')).toBeInTheDocument() - expect(screen.getByText('Import Caddyfile')).toBeInTheDocument() + // Expand Tasks and Import to see nested items + await userEvent.click(screen.getByText('Tasks')) + expect(screen.getByText('Import')).toBeInTheDocument() + await userEvent.click(screen.getByText('Import')) + expect(screen.getByText('Caddyfile')).toBeInTheDocument() + expect(screen.getByText('CrowdSec')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() }) diff --git a/frontend/src/pages/ImportCaddy.tsx b/frontend/src/pages/ImportCaddy.tsx index 71bfb900..2c836616 100644 --- a/frontend/src/pages/ImportCaddy.tsx +++ b/frontend/src/pages/ImportCaddy.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { createBackup } from '../api/backups' import { useImport } from '../hooks/useImport' import ImportBanner from '../components/ImportBanner' import ImportReviewTable from '../components/ImportReviewTable' @@ -34,6 +35,8 @@ export default function ImportCaddy() { const handleCommit = async (resolutions: Record, names: Record) => { try { + // Create a backup before committing import to allow rollback + await createBackup() await commit(resolutions, names) setContent('') setShowReview(false) diff --git a/frontend/src/pages/ImportCrowdSec.tsx b/frontend/src/pages/ImportCrowdSec.tsx new file mode 100644 index 00000000..be733970 --- /dev/null +++ b/frontend/src/pages/ImportCrowdSec.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { importCrowdsecConfig } from '../api/crowdsec' +import { createBackup } from '../api/backups' +import { Button } from '../components/ui/Button' +import { Card } from '../components/ui/Card' +import { toast } from 'react-hot-toast' + +export default function ImportCrowdSec() { + const [file, setFile] = useState(null) + + const backupMutation = useMutation({ + mutationFn: () => createBackup(), + }) + + const importMutation = useMutation({ + mutationFn: async (file: File) => importCrowdsecConfig(file), + onSuccess: () => { + toast.success('CrowdSec config imported') + }, + onError: (e: unknown) => { + const msg = e instanceof Error ? e.message : String(e) + toast.error(`Import failed: ${msg}`) + } + }) + + const handleFile = (e: React.ChangeEvent) => { + const f = e.target.files?.[0] + if (!f) return + setFile(f) + } + + const handleImport = async () => { + if (!file) return + try { + toast.loading('Creating backup...') + await backupMutation.mutateAsync() + toast.dismiss() + toast.loading('Importing CrowdSec...') + await importMutation.mutateAsync(file) + toast.dismiss() + } catch { + toast.dismiss() + // importMutation onError handles toast + } + } + + return ( +
+

Import CrowdSec

+ +
+

Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.

+ +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Uptime.tsx b/frontend/src/pages/Uptime.tsx index 3b239624..77eedf28 100644 --- a/frontend/src/pages/Uptime.tsx +++ b/frontend/src/pages/Uptime.tsx @@ -84,6 +84,21 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) > Configure + -
)} @@ -176,11 +177,12 @@ Message: ${beat.message}`} const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void }> = ({ monitor, onClose }) => { const queryClient = useQueryClient(); + const [name, setName] = useState(monitor.name || '') const [maxRetries, setMaxRetries] = useState(monitor.max_retries || 3); const [interval, setInterval] = useState(monitor.interval || 60); const mutation = useMutation({ - mutationFn: (data: Partial) => updateMonitor(monitor.id, data), + mutationFn: (data: Partial) => updateMonitor(monitor.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['monitors'] }); onClose(); @@ -189,7 +191,7 @@ const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void }> = ({ const handleSubmit = (e: FormEvent) => { e.preventDefault(); - mutation.mutate({ max_retries: maxRetries, interval }); + mutation.mutate({ name, max_retries: maxRetries, interval }); }; return ( @@ -203,6 +205,18 @@ const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void }> = ({
+
+ + setName(e.target.value)} + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +