feat: add ImportCrowdSec page and integrate with backup functionality; refactor navigation structure
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user