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 (
+
+
toggleMenu(nestedExpandedKey)}
+ className={`w-full flex items-center justify-between py-2 px-3 rounded-md text-sm transition-colors ${
+ location.pathname.startsWith(child.path!)
+ ? 'text-blue-700 dark:text-blue-400'
+ : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
+ }`}
+ >
+
+ {child.icon}
+ {child.name}
+
+ {isNestedOpen ? (
+
+ ) : (
+
+ )}
+
+ {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.
+
+
+ handleImport()} isLoading={backupMutation.isPending || importMutation.isPending} disabled={!file}>Import
+
+
+
+
+ )
+}
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
+ {
+ setShowMenu(false)
+ try {
+ await toggleMutation.mutateAsync({ id: monitor.id, enabled: !monitor.enabled })
+ toast.success(`${monitor.enabled ? 'Paused' : 'Unpaused'}`)
+ } catch {
+ // handled in onError
+ }
+ }}
+ className="w-full text-left px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-gray-900 flex items-center gap-2"
+ >
+
+ {monitor.enabled ? 'Pause' : 'Unpause'}
+
{
setShowMenu(false)
@@ -99,20 +114,6 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
>
Delete
- {
- setShowMenu(false)
- try {
- await toggleMutation.mutateAsync({ id: monitor.id, enabled: !monitor.enabled })
- toast.success(`${monitor.enabled ? 'Monitoring disabled' : 'Monitoring enabled'}`)
- } catch {
- // handled in onError
- }
- }}
- className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-900"
- >
- {monitor.enabled ? 'Disable Monitoring' : 'Enable Monitoring'}
-
)}
@@ -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 }> = ({