feat: add ImportCrowdSec page and integrate with backup functionality; refactor navigation structure

This commit is contained in:
GitHub Actions
2025-11-30 16:12:23 +00:00
parent 92697ec5ec
commit 215c2fe478
8 changed files with 214 additions and 22 deletions
+5
View File
@@ -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() {
<Route index element={<Backups />} />
<Route path="backups" element={<Backups />} />
<Route path="logs" element={<Logs />} />
<Route path="import">
<Route path="caddyfile" element={<ImportCaddy />} />
<Route path="crowdsec" element={<ImportCrowdSec />} />
</Route>
</Route>
</Route>
+56 -1
View File
@@ -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 && (
<div className="pl-11 space-y-1">
{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 (
<div key={child.path} className="space-y-1">
<button
onClick={() => 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'
}`}
>
<div className="flex items-center gap-2">
<span className="text-lg">{child.icon}</span>
<span>{child.name}</span>
</div>
{isNestedOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{isNestedOpen && (
<div className="pl-6 space-y-1">
{(child as any).children.map((sub: any) => (
<Link
key={sub.path}
to={sub.path}
onClick={() => 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}
</Link>
))}
</div>
)}
</div>
)
}
const isChildActive = location.pathname === child.path
return (
<Link
@@ -57,7 +57,7 @@ describe('Layout', () => {
expect(logos[0]).toBeInTheDocument()
})
it('renders all navigation items', () => {
it('renders all navigation items', async () => {
renderWithProviders(
<Layout>
<div>Test Content</div>
@@ -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()
})
+3
View File
@@ -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<string, string>, names: Record<string, string>) => {
try {
// Create a backup before committing import to allow rollback
await createBackup()
await commit(resolutions, names)
setContent('')
setShowReview(false)
+62
View File
@@ -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<File | null>(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<HTMLInputElement>) => {
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 (
<div className="p-8">
<h1 className="text-3xl font-bold text-white mb-6">Import CrowdSec</h1>
<Card className="p-6">
<div className="space-y-4">
<p className="text-sm text-gray-400">Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.</p>
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" />
<div className="flex gap-2">
<Button onClick={() => handleImport()} isLoading={backupMutation.isPending || importMutation.isPending} disabled={!file}>Import</Button>
</div>
</div>
</Card>
</div>
)
}
+30 -16
View File
@@ -84,6 +84,21 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
>
Configure
</button>
<button
onClick={async () => {
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"
>
<Pause className="w-4 h-4 mr-1" />
{monitor.enabled ? 'Pause' : 'Unpause'}
</button>
<button
onClick={async () => {
setShowMenu(false)
@@ -99,20 +114,6 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
>
Delete
</button>
<button
onClick={async () => {
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'}
</button>
</div>
)}
</div>
@@ -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<UptimeMonitor>) => updateMonitor(monitor.id, data),
mutationFn: (data: Partial<UptimeMonitor>) => 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 }> = ({
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="monitor-name" className="block text-sm font-medium text-gray-300 mb-1">
Name
</label>
<input
id="monitor-name"
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Max Retries
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import userEvent from '@testing-library/user-event'
import ImportCrowdSec from '../ImportCrowdSec'
import * as api from '../../api/crowdsec'
import * as backups from '../../api/backups'
vi.mock('../../api/crowdsec')
vi.mock('../../api/backups')
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const renderWithProviders = (ui: React.ReactNode) => {
const qc = createQueryClient()
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
{ui}
</BrowserRouter>
</QueryClientProvider>
)
}
describe('ImportCrowdSec page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates a backup then imports crowdsec', async () => {
const file = new File(['fake'], 'crowdsec.zip', { type: 'application/zip' })
vi.mocked(backups.createBackup).mockResolvedValue({ filename: 'b1' })
vi.mocked(api.importCrowdsecConfig).mockResolvedValue({ success: true })
renderWithProviders(<ImportCrowdSec />)
const fileInput = document.querySelector('input[type="file"]')
expect(fileInput).toBeTruthy()
fireEvent.change(fileInput!, { target: { files: [file] } })
const importBtn = screen.getByText('Import')
await userEvent.click(importBtn)
await waitFor(() => expect(backups.createBackup).toHaveBeenCalled())
await waitFor(() => expect(api.importCrowdsecConfig).toHaveBeenCalledWith(file))
})
})
+5 -3
View File
@@ -44,7 +44,7 @@ describe('Uptime page', () => {
const card = screen.getByText('Test Monitor').closest('div') as HTMLElement
const settingsBtn = within(card).getByTitle('Monitor settings')
await userEvent.click(settingsBtn)
const toggleBtn = within(card).getByText('Disable Monitoring')
const toggleBtn = within(card).getByText('Pause')
await userEvent.click(toggleBtn)
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m1', { enabled: false }))
})
@@ -141,8 +141,10 @@ describe('Uptime page', () => {
const maxRetriesInput = spinbuttons.find(el => el.getAttribute('value') === '3') as HTMLInputElement
await userEvent.clear(maxRetriesInput)
await userEvent.type(maxRetriesInput, '6')
await userEvent.clear(screen.getByLabelText('Name'))
await userEvent.type(screen.getByLabelText('Name'), 'Renamed Monitor')
await userEvent.click(screen.getByText('Save Changes'))
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m6', { max_retries: 6, interval: 60 }))
await waitFor(() => expect(uptimeApi.updateMonitor).toHaveBeenCalledWith('m6', { name: 'Renamed Monitor', max_retries: 6, interval: 60 }))
})
it('does not call deleteMonitor when canceling delete', async () => {
@@ -177,7 +179,7 @@ describe('Uptime page', () => {
await waitFor(() => expect(screen.getByText('ToggleFail')).toBeInTheDocument())
const card = screen.getByText('ToggleFail').closest('div') as HTMLElement
await userEvent.click(within(card).getByTitle('Monitor settings'))
await userEvent.click(within(card).getByText('Disable Monitoring'))
await userEvent.click(within(card).getByText('Pause'))
const toast = (await import('react-hot-toast')).toast
await waitFor(() => expect(toast.error).toHaveBeenCalled())
})