feat: add Top Attacking IPs chart component and integrate into CrowdSec configuration page

- Implemented TopAttackingIPsChart component for visualizing top attacking IPs.
- Created hooks for fetching CrowdSec dashboard data including summary, timeline, top IPs, scenarios, and alerts.
- Added tests for the new hooks to ensure data fetching works as expected.
- Updated translation files for new dashboard terms in multiple languages.
- Refactored CrowdSecConfig page to include a tabbed interface for configuration and dashboard views.
- Added end-to-end tests for CrowdSec dashboard functionality including tab navigation, data display, and interaction with time range and refresh features.
This commit is contained in:
GitHub Actions
2026-03-25 17:16:54 +00:00
parent 846eedeab0
commit 1fe69c2a15
41 changed files with 5910 additions and 540 deletions
@@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { ActiveDecisionsTable } from '../crowdsec/ActiveDecisionsTable'
import type { TopIP } from '../../api/crowdsecDashboard'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback ?? _key,
}),
}))
const mockTopIPs: TopIP[] = [
{
ip: '192.168.1.1',
count: 25,
last_seen: new Date(Date.now() - 3600_000).toISOString(),
country: 'US',
},
{
ip: '10.0.0.1',
count: 12,
last_seen: new Date(Date.now() - 7200_000).toISOString(),
country: 'DE',
},
]
describe('ActiveDecisionsTable', () => {
it('renders loading skeleton', () => {
render(
<ActiveDecisionsTable data={undefined} isLoading isError={false} />,
)
expect(screen.queryByText('Active Decisions')).not.toBeInTheDocument()
expect(screen.queryByText('Failed to load decisions.')).not.toBeInTheDocument()
})
it('renders error state', () => {
render(<ActiveDecisionsTable data={undefined} isLoading={false} isError />)
expect(screen.getByText('Failed to load decisions.')).toBeInTheDocument()
})
it('renders empty state', () => {
render(<ActiveDecisionsTable data={[]} isLoading={false} isError={false} />)
expect(screen.getByText('No active decisions.')).toBeInTheDocument()
})
it('renders table with top IP data', () => {
render(<ActiveDecisionsTable data={mockTopIPs} isLoading={false} isError={false} />)
expect(screen.getByText('Active Decisions')).toBeInTheDocument()
expect(screen.getByText('192.168.1.1')).toBeInTheDocument()
expect(screen.getByText('10.0.0.1')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
expect(screen.getByText('12')).toBeInTheDocument()
expect(screen.getByText('US')).toBeInTheDocument()
expect(screen.getByText('DE')).toBeInTheDocument()
})
it('renders column headers with sort buttons', () => {
render(<ActiveDecisionsTable data={mockTopIPs} isLoading={false} isError={false} />)
expect(screen.getByRole('button', { name: /Sort by IP/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Sort by Alerts/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Sort by Last Seen/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Sort by Country/i })).toBeInTheDocument()
})
it('toggles sort direction when clicking the same column', async () => {
const user = userEvent.setup()
render(<ActiveDecisionsTable data={mockTopIPs} isLoading={false} isError={false} />)
const ipSortButton = screen.getByRole('button', { name: /Sort by IP/i })
await user.click(ipSortButton)
const ipHeader = screen.getByRole('columnheader', { name: /IP/i })
expect(ipHeader).toHaveAttribute('aria-sort', 'descending')
await user.click(ipSortButton)
expect(ipHeader).toHaveAttribute('aria-sort', 'ascending')
})
it('shows dash for missing country', () => {
const data: TopIP[] = [{ ip: '1.2.3.4', count: 1, last_seen: new Date().toISOString(), country: '' }]
render(<ActiveDecisionsTable data={data} isLoading={false} isError={false} />)
expect(screen.getByText('—')).toBeInTheDocument()
})
})
@@ -0,0 +1,162 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { AlertsList } from '../crowdsec/AlertsList'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallbackOrOpts?: string | Record<string, unknown>, opts?: Record<string, unknown>) => {
const fallback = typeof fallbackOrOpts === 'string' ? fallbackOrOpts : key
const params = typeof fallbackOrOpts === 'object' ? fallbackOrOpts : opts
if (!params) return fallback
return fallback.replace(/\{\{(\w+)\}\}/g, (_, k) => String(params[k] ?? ''))
},
}),
}))
const mockAlerts = {
data: {
alerts: [
{
id: 1,
scenario: 'crowdsecurity/http-bad-user-agent',
ip: '192.168.1.100',
message: 'test alert',
events_count: 5,
start_at: '2025-01-01T00:00:00Z',
stop_at: '2025-01-01T01:00:00Z',
created_at: '2025-01-01T00:00:00Z',
duration: '4h',
type: 'ban',
origin: 'crowdsec',
},
{
id: 2,
scenario: 'crowdsecurity/ssh-bf',
ip: '10.0.0.1',
message: 'ssh bruteforce',
events_count: 12,
start_at: '2025-01-01T00:00:00Z',
stop_at: '2025-01-01T01:00:00Z',
created_at: '2025-01-01T00:30:00Z',
duration: '4h',
type: 'ban',
origin: 'crowdsec',
},
],
total: 2,
source: 'cscli',
cached: false,
},
isLoading: false,
isError: false,
}
const mockUseAlerts = vi.fn((_params?: unknown): { data: typeof mockAlerts['data'] | undefined; isLoading: boolean; isError: boolean } => mockAlerts)
vi.mock('../../hooks/useCrowdsecDashboard', () => ({
useAlerts: (params: unknown) => mockUseAlerts(params),
}))
describe('AlertsList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAlerts.mockReturnValue(mockAlerts)
})
it('renders alert rows with IP, scenario, and events count', () => {
renderWithQueryClient(<AlertsList range="24h" />)
expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
expect(screen.getByText('10.0.0.1')).toBeInTheDocument()
expect(screen.getByText('http-bad-user-agent')).toBeInTheDocument()
expect(screen.getByText('ssh-bf')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument()
expect(screen.getByText('12')).toBeInTheDocument()
})
it('renders the heading and total count', () => {
renderWithQueryClient(<AlertsList range="24h" />)
expect(screen.getByText('Recent Alerts')).toBeInTheDocument()
expect(screen.getByText('2 total')).toBeInTheDocument()
})
it('shows loading skeleton when isLoading', () => {
mockUseAlerts.mockReturnValue({ data: undefined, isLoading: true, isError: false })
renderWithQueryClient(<AlertsList range="24h" />)
expect(screen.getByTestId('alerts-list')).toBeInTheDocument()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
it('shows error state when isError', () => {
mockUseAlerts.mockReturnValue({ data: undefined, isLoading: false, isError: true })
renderWithQueryClient(<AlertsList range="24h" />)
expect(screen.getByText('Failed to load alerts.')).toBeInTheDocument()
})
it('shows empty state when no alerts exist', () => {
mockUseAlerts.mockReturnValue({
data: { alerts: [], total: 0, source: 'cscli', cached: false },
isLoading: false,
isError: false,
})
renderWithQueryClient(<AlertsList range="24h" />)
expect(screen.getByText('No alerts for the selected period.')).toBeInTheDocument()
})
it('renders pagination when there are more than PAGE_SIZE alerts', async () => {
const manyAlerts = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
scenario: 'crowdsecurity/http-bf',
ip: `10.0.0.${i + 1}`,
message: 'test',
events_count: 1,
start_at: '2025-01-01T00:00:00Z',
stop_at: '2025-01-01T01:00:00Z',
created_at: '2025-01-01T00:00:00Z',
duration: '4h',
type: 'ban',
origin: 'crowdsec',
}))
mockUseAlerts.mockReturnValue({
data: { alerts: manyAlerts, total: 25, source: 'cscli', cached: false },
isLoading: false,
isError: false,
})
const user = userEvent.setup()
renderWithQueryClient(<AlertsList range="24h" />)
const nav = screen.getByRole('navigation', { name: /pagination/i })
expect(nav).toBeInTheDocument()
expect(within(nav).getByText('Page 1 of 3')).toBeInTheDocument()
const nextBtn = screen.getByRole('button', { name: /next page/i })
expect(nextBtn).not.toBeDisabled()
const prevBtn = screen.getByRole('button', { name: /previous page/i })
expect(prevBtn).toBeDisabled()
await user.click(nextBtn)
expect(mockUseAlerts).toHaveBeenCalledWith(
expect.objectContaining({ offset: 10 })
)
})
it('does not render pagination for single page', () => {
renderWithQueryClient(<AlertsList range="24h" />)
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BanTimelineChart } from '../crowdsec/BanTimelineChart'
import type { TimelineBucket } from '../../api/crowdsecDashboard'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback ?? _key,
}),
}))
vi.mock('recharts', async () => {
const Original = await vi.importActual('recharts')
return {
...Original,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
}
})
const mockBuckets: TimelineBucket[] = [
{ timestamp: '2025-01-01T00:00:00Z', bans: 10, captchas: 3 },
{ timestamp: '2025-01-01T01:00:00Z', bans: 15, captchas: 5 },
{ timestamp: '2025-01-01T02:00:00Z', bans: 8, captchas: 2 },
]
describe('BanTimelineChart', () => {
it('renders loading skeleton', () => {
render(
<BanTimelineChart data={undefined} isLoading isError={false} range="24h" />,
)
expect(screen.queryByText('Decision Timeline')).not.toBeInTheDocument()
expect(screen.queryByText('Failed to load timeline data.')).not.toBeInTheDocument()
})
it('renders error state', () => {
render(<BanTimelineChart data={undefined} isLoading={false} isError range="24h" />)
expect(screen.getByText('Failed to load timeline data.')).toBeInTheDocument()
})
it('renders empty state', () => {
render(<BanTimelineChart data={[]} isLoading={false} isError={false} range="24h" />)
expect(screen.getByText('No decision data for the selected period.')).toBeInTheDocument()
})
it('renders chart with data', () => {
render(<BanTimelineChart data={mockBuckets} isLoading={false} isError={false} range="24h" />)
expect(screen.getByText('Decision Timeline')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'Area chart showing bans and captchas over time',
)
})
})
@@ -0,0 +1,99 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { CrowdSecDashboard } from '../crowdsec/CrowdSecDashboard'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback ?? _key,
}),
}))
const mockSummary = {
data: {
total_decisions: 100,
active_decisions: 10,
unique_ips: 50,
top_scenario: 'crowdsecurity/http-bad-user-agent',
decisions_trend: 0,
range: '24h',
cached: false,
generated_at: '2025-01-01T00:00:00Z',
},
isLoading: false,
isError: false,
}
const mockTimeline = {
data: { buckets: [], range: '24h', interval: '1h', cached: false },
isLoading: false,
isError: false,
}
const mockTopIPs = {
data: { ips: [], range: '24h', cached: false },
isLoading: false,
isError: false,
}
const mockScenarios = {
data: { scenarios: [], total: 0, range: '24h', cached: false },
isLoading: false,
isError: false,
}
vi.mock('../../hooks/useCrowdsecDashboard', () => ({
useDashboardSummary: () => mockSummary,
useDashboardTimeline: () => mockTimeline,
useDashboardTopIPs: () => mockTopIPs,
useDashboardScenarios: () => mockScenarios,
useAlerts: () => ({ data: undefined, isLoading: false, isError: false }),
}))
vi.mock('recharts', async () => {
const Original = await vi.importActual('recharts')
return {
...Original,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
}
})
describe('CrowdSecDashboard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the time range selector', () => {
renderWithQueryClient(<CrowdSecDashboard />)
expect(screen.getByRole('radiogroup')).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '24H' })).toHaveAttribute('aria-checked', 'true')
})
it('renders the refresh button', () => {
renderWithQueryClient(<CrowdSecDashboard />)
expect(screen.getByRole('button', { name: /Refresh/i })).toBeInTheDocument()
})
it('renders summary cards section', () => {
renderWithQueryClient(<CrowdSecDashboard />)
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('10')).toBeInTheDocument()
})
it('switches time range when selector is clicked', async () => {
const user = userEvent.setup()
renderWithQueryClient(<CrowdSecDashboard />)
await user.click(screen.getByRole('radio', { name: '1H' }))
expect(screen.getByRole('radio', { name: '1H' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: '24H' })).toHaveAttribute('aria-checked', 'false')
})
})
@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { DashboardSummaryCards } from '../crowdsec/DashboardSummaryCards'
import type { DashboardSummary } from '../../api/crowdsecDashboard'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback ?? _key,
}),
}))
const mockSummary: DashboardSummary = {
total_decisions: 1234,
active_decisions: 42,
unique_ips: 89,
top_scenario: 'crowdsecurity/http-bad-user-agent',
decisions_trend: 12.5,
range: '24h',
cached: false,
generated_at: '2025-01-01T00:00:00Z',
}
describe('DashboardSummaryCards', () => {
it('renders loading skeletons', () => {
render(<DashboardSummaryCards data={undefined} isLoading isError={false} />)
expect(screen.getByTestId('dashboard-summary-cards')).toBeInTheDocument()
expect(screen.queryByText('Total Decisions')).not.toBeInTheDocument()
})
it('renders error state', () => {
render(<DashboardSummaryCards data={undefined} isLoading={false} isError />)
expect(screen.getByText('Failed to load summary data.')).toBeInTheDocument()
})
it('renders summary data with correct values', () => {
render(<DashboardSummaryCards data={mockSummary} isLoading={false} isError={false} />)
expect(screen.getByText('1,234')).toBeInTheDocument()
expect(screen.getByText('42')).toBeInTheDocument()
expect(screen.getByText('89')).toBeInTheDocument()
expect(screen.getByText('http-bad-user-agent')).toBeInTheDocument()
})
it('displays positive trend with + prefix', () => {
render(<DashboardSummaryCards data={mockSummary} isLoading={false} isError={false} />)
expect(screen.getByText('+12.5%')).toBeInTheDocument()
})
it('displays negative trend', () => {
const data = { ...mockSummary, decisions_trend: -5.2 }
render(<DashboardSummaryCards data={data} isLoading={false} isError={false} />)
expect(screen.getByText('-5.2%')).toBeInTheDocument()
})
it('shows N/A when active_decisions is -1', () => {
const data = { ...mockSummary, active_decisions: -1 }
render(<DashboardSummaryCards data={data} isLoading={false} isError={false} />)
expect(screen.getByText('N/A')).toBeInTheDocument()
expect(screen.getByText('LAPI unavailable')).toBeInTheDocument()
})
it('has aria-live attribute for screen reader updates', () => {
render(<DashboardSummaryCards data={mockSummary} isLoading={false} isError={false} />)
expect(screen.getByTestId('dashboard-summary-cards')).toHaveAttribute('aria-live', 'polite')
})
})
@@ -0,0 +1,109 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { DashboardTimeRangeSelector } from '../crowdsec/DashboardTimeRangeSelector'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback: string) => fallback ?? key,
}),
}))
describe('DashboardTimeRangeSelector', () => {
it('renders all time range options', () => {
render(<DashboardTimeRangeSelector value="24h" onChange={vi.fn()} />)
expect(screen.getByRole('radio', { name: '1H' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '6H' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '24H' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '7D' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '30D' })).toBeInTheDocument()
})
it('marks the selected range as aria-checked', () => {
render(<DashboardTimeRangeSelector value="7d" onChange={vi.fn()} />)
expect(screen.getByRole('radio', { name: '7D' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: '24H' })).toHaveAttribute('aria-checked', 'false')
})
it('applies roving tabindex — only selected has tabIndex 0', () => {
render(<DashboardTimeRangeSelector value="24h" onChange={vi.fn()} />)
expect(screen.getByRole('radio', { name: '24H' })).toHaveAttribute('tabindex', '0')
expect(screen.getByRole('radio', { name: '1H' })).toHaveAttribute('tabindex', '-1')
expect(screen.getByRole('radio', { name: '30D' })).toHaveAttribute('tabindex', '-1')
})
it('calls onChange when a range is clicked', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<DashboardTimeRangeSelector value="24h" onChange={onChange} />)
await user.click(screen.getByRole('radio', { name: '1H' }))
expect(onChange).toHaveBeenCalledWith('1h')
})
it('uses radiogroup role on the container', () => {
render(<DashboardTimeRangeSelector value="24h" onChange={vi.fn()} />)
expect(screen.getByRole('radiogroup')).toBeInTheDocument()
})
it('navigates forward with ArrowRight', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<DashboardTimeRangeSelector value="24h" onChange={onChange} />)
screen.getByRole('radio', { name: '24H' }).focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith('7d')
})
it('navigates backward with ArrowLeft', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<DashboardTimeRangeSelector value="24h" onChange={onChange} />)
screen.getByRole('radio', { name: '24H' }).focus()
await user.keyboard('{ArrowLeft}')
expect(onChange).toHaveBeenCalledWith('6h')
})
it('wraps around from last to first with ArrowRight', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<DashboardTimeRangeSelector value="30d" onChange={onChange} />)
screen.getByRole('radio', { name: '30D' }).focus()
await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenCalledWith('1h')
})
it('jumps to first with Home key', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<DashboardTimeRangeSelector value="7d" onChange={onChange} />)
screen.getByRole('radio', { name: '7D' }).focus()
await user.keyboard('{Home}')
expect(onChange).toHaveBeenCalledWith('1h')
})
it('jumps to last with End key', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<DashboardTimeRangeSelector value="1h" onChange={onChange} />)
screen.getByRole('radio', { name: '1H' }).focus()
await user.keyboard('{End}')
expect(onChange).toHaveBeenCalledWith('30d')
})
})
@@ -0,0 +1,105 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { DecisionsExportButton } from '../crowdsec/DecisionsExportButton'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback ?? _key,
}),
}))
const mockExportDecisions = vi.fn()
vi.mock('../../api/crowdsecDashboard', () => ({
exportDecisions: (...args: unknown[]) => mockExportDecisions(...args),
}))
describe('DecisionsExportButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExportDecisions.mockResolvedValue(new Blob(['test'], { type: 'text/csv' }))
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
})
it('renders the export button', () => {
renderWithQueryClient(<DecisionsExportButton range="24h" />)
expect(screen.getByRole('button', { name: /export decisions/i })).toBeInTheDocument()
})
it('opens dropdown menu on click', async () => {
const user = userEvent.setup()
renderWithQueryClient(<DecisionsExportButton range="24h" />)
await user.click(screen.getByRole('button', { name: /export decisions/i }))
expect(screen.getByRole('menu')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /csv/i })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /json/i })).toBeInTheDocument()
})
it('triggers CSV export when CSV option is clicked', async () => {
const user = userEvent.setup()
renderWithQueryClient(<DecisionsExportButton range="24h" />)
await user.click(screen.getByRole('button', { name: /export decisions/i }))
await user.click(screen.getByRole('menuitem', { name: /csv/i }))
await waitFor(() => {
expect(mockExportDecisions).toHaveBeenCalledWith('csv', '24h')
})
})
it('triggers JSON export when JSON option is clicked', async () => {
const user = userEvent.setup()
renderWithQueryClient(<DecisionsExportButton range="7d" />)
await user.click(screen.getByRole('button', { name: /export decisions/i }))
await user.click(screen.getByRole('menuitem', { name: /json/i }))
await waitFor(() => {
expect(mockExportDecisions).toHaveBeenCalledWith('json', '7d')
})
})
it('shows error message when export fails', async () => {
mockExportDecisions.mockRejectedValue(new Error('Server error'))
const user = userEvent.setup()
renderWithQueryClient(<DecisionsExportButton range="24h" />)
await user.click(screen.getByRole('button', { name: /export decisions/i }))
await user.click(screen.getByRole('menuitem', { name: /csv/i }))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Export failed')
})
})
it('closes dropdown on Escape key', async () => {
const user = userEvent.setup()
renderWithQueryClient(<DecisionsExportButton range="24h" />)
await user.click(screen.getByRole('button', { name: /export decisions/i }))
expect(screen.getByRole('menu')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('sets aria-expanded attribute correctly', async () => {
const user = userEvent.setup()
renderWithQueryClient(<DecisionsExportButton range="24h" />)
const btn = screen.getByRole('button', { name: /export decisions/i })
expect(btn).toHaveAttribute('aria-expanded', 'false')
await user.click(btn)
expect(btn).toHaveAttribute('aria-expanded', 'true')
})
})
@@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { ScenarioBreakdownChart } from '../crowdsec/ScenarioBreakdownChart'
import type { ScenarioEntry } from '../../api/crowdsecDashboard'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback ?? _key,
}),
}))
vi.mock('recharts', async () => {
const Original = await vi.importActual('recharts')
return {
...Original,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
}
})
const mockScenarios: ScenarioEntry[] = [
{ name: 'crowdsecurity/http-bad-user-agent', count: 100, percentage: 50 },
{ name: 'crowdsecurity/ssh-bf', count: 60, percentage: 30 },
{ name: 'crowdsecurity/http-probing', count: 40, percentage: 20 },
]
describe('ScenarioBreakdownChart', () => {
it('renders loading skeleton', () => {
render(
<ScenarioBreakdownChart data={undefined} isLoading isError={false} />,
)
expect(screen.queryByText('Scenario Breakdown')).not.toBeInTheDocument()
expect(screen.queryByText('Failed to load scenario data.')).not.toBeInTheDocument()
})
it('renders error state', () => {
render(<ScenarioBreakdownChart data={undefined} isLoading={false} isError />)
expect(screen.getByText('Failed to load scenario data.')).toBeInTheDocument()
})
it('renders empty state', () => {
render(<ScenarioBreakdownChart data={[]} isLoading={false} isError={false} />)
expect(screen.getByText('No scenario data for the selected period.')).toBeInTheDocument()
})
it('renders chart and legend with data', () => {
render(<ScenarioBreakdownChart data={mockScenarios} isLoading={false} isError={false} />)
expect(screen.getByText('Scenario Breakdown')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'Donut chart showing distribution of scenarios by decision count',
)
expect(screen.getByText('http-bad-user-agent')).toBeInTheDocument()
expect(screen.getByText('ssh-bf')).toBeInTheDocument()
expect(screen.getByText('http-probing')).toBeInTheDocument()
})
it('renders legend with counts and percentages', () => {
render(<ScenarioBreakdownChart data={mockScenarios} isLoading={false} isError={false} />)
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('50.0%')).toBeInTheDocument()
expect(screen.getByText('60')).toBeInTheDocument()
expect(screen.getByText('30.0%')).toBeInTheDocument()
})
})
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TopAttackingIPsChart } from '../crowdsec/TopAttackingIPsChart'
import type { TopIP } from '../../api/crowdsecDashboard'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (_key: string, fallback: string) => fallback ?? _key,
}),
}))
vi.mock('recharts', async () => {
const Original = await vi.importActual('recharts')
return {
...Original,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
}
})
const mockIPs: TopIP[] = [
{ ip: '192.168.1.1', count: 50, last_seen: '2025-01-01T00:00:00Z', country: 'US' },
{ ip: '10.0.0.1', count: 30, last_seen: '2025-01-01T01:00:00Z', country: 'CN' },
]
describe('TopAttackingIPsChart', () => {
it('renders loading skeleton', () => {
render(
<TopAttackingIPsChart data={undefined} isLoading isError={false} />,
)
expect(screen.queryByText('Top Attacking IPs')).not.toBeInTheDocument()
expect(screen.queryByText('Failed to load top IPs data.')).not.toBeInTheDocument()
})
it('renders error state', () => {
render(<TopAttackingIPsChart data={undefined} isLoading={false} isError />)
expect(screen.getByText('Failed to load top IPs data.')).toBeInTheDocument()
})
it('renders empty state', () => {
render(<TopAttackingIPsChart data={[]} isLoading={false} isError={false} />)
expect(screen.getByText('No attacking IPs in the selected period.')).toBeInTheDocument()
})
it('renders chart with data and accessible label', () => {
render(<TopAttackingIPsChart data={mockIPs} isLoading={false} isError={false} />)
expect(screen.getByText('Top Attacking IPs')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'Horizontal bar chart showing top attacking IPs by decision count',
)
})
})
@@ -0,0 +1,155 @@
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'
import { useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '../ui'
import type { TopIP } from '../../api/crowdsecDashboard'
// TODO: Replace TopIP[] with a dedicated ActiveDecision[] type backed by /v1/decisions once endpoints are available
interface ActiveDecisionsTableProps {
data: TopIP[] | undefined
isLoading: boolean
isError: boolean
}
type SortKey = 'ip' | 'count' | 'last_seen' | 'country'
type SortDir = 'asc' | 'desc'
function formatRelativeTime(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffMs = now - then
if (diffMs < 0) return 'just now'
const seconds = Math.floor(diffMs / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function ActiveDecisionsTable({ data, isLoading, isError }: ActiveDecisionsTableProps) {
const { t } = useTranslation()
const [sortKey, setSortKey] = useState<SortKey>('count')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const toggleSort = useCallback((key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('desc')
}
}, [sortKey])
const sorted = useMemo(() => {
if (!data) return []
return [...data].sort((a, b) => {
const aVal = a[sortKey]
const bVal = b[sortKey]
let cmp: number
if (typeof aVal === 'number' && typeof bVal === 'number') {
cmp = aVal - bVal
} else {
cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''), undefined, { numeric: true })
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [data, sortKey, sortDir])
if (isLoading) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<Skeleton className="h-4 w-40 mb-4" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (isError) {
return (
<div className="rounded-lg border border-red-700/50 bg-red-900/20 p-4">
<p className="text-sm text-red-300">{t('security.crowdsec.dashboard.decisionsError', 'Failed to load decisions.')}</p>
</div>
)
}
if (!sorted.length) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4 text-center text-gray-400 py-12">
<p>{t('security.crowdsec.dashboard.noDecisions', 'No active decisions.')}</p>
</div>
)
}
const columns: { key: SortKey; label: string }[] = [
{ key: 'ip', label: t('security.crowdsec.dashboard.colIP', 'IP') },
{ key: 'count', label: t('security.crowdsec.dashboard.colCount', 'Alerts') },
{ key: 'last_seen', label: t('security.crowdsec.dashboard.colLastSeen', 'Last Seen') },
{ key: 'country', label: t('security.crowdsec.dashboard.colCountry', 'Country') },
]
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-4">
{t('security.crowdsec.dashboard.activeDecisionsTable', 'Active Decisions')}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead>
<tr className="border-b border-gray-700">
{columns.map((col) => {
const isSorted = sortKey === col.key
const ariaSortValue = isSorted ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSortValue}
className="py-2 px-3 text-gray-400 font-medium whitespace-nowrap"
>
<button
type="button"
className="inline-flex items-center gap-1 hover:text-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 rounded"
onClick={() => toggleSort(col.key)}
aria-label={`${t('security.crowdsec.dashboard.sortBy', 'Sort by')} ${col.label}`}
>
{col.label}
{isSorted
? sortDir === 'asc'
? <ArrowUp className="h-3 w-3" aria-hidden="true" />
: <ArrowDown className="h-3 w-3" aria-hidden="true" />
: <ArrowUpDown className="h-3 w-3 text-gray-600" aria-hidden="true" />
}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr key={`${row.ip}-${i}`} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-2 px-3 font-mono text-gray-300 whitespace-nowrap">{row.ip}</td>
<td className="py-2 px-3 text-gray-400 tabular-nums">{row.count}</td>
<td className="py-2 px-3 text-gray-400 whitespace-nowrap tabular-nums">
<time dateTime={row.last_seen} title={new Date(row.last_seen).toLocaleString()}>
{formatRelativeTime(row.last_seen)}
</time>
</td>
<td className="py-2 px-3 text-gray-400">{row.country || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,187 @@
import { AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react'
import { useState, useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useAlerts } from '../../hooks/useCrowdsecDashboard'
import { Skeleton } from '../ui'
import type { TimeRange } from '../../api/crowdsecDashboard'
interface AlertsListProps {
range: TimeRange
}
const PAGE_SIZE = 10
function formatRelativeTime(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffMs = now - then
if (diffMs < 0) return 'just now'
const seconds = Math.floor(diffMs / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function AlertsList({ range }: AlertsListProps) {
const { t } = useTranslation()
const [page, setPage] = useState(0)
useEffect(() => {
setPage(0)
}, [range])
const { data, isLoading, isError } = useAlerts({
range,
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
})
const totalPages = useMemo(() => {
if (!data?.total) return 0
return Math.ceil(data.total / PAGE_SIZE)
}, [data?.total])
const handlePreviousPage = () => {
setPage((p) => Math.max(0, p - 1))
}
const handleNextPage = () => {
setPage((p) => (totalPages > 0 ? Math.min(totalPages - 1, p + 1) : 0))
}
if (isLoading) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4" data-testid="alerts-list">
<Skeleton className="h-4 w-32 mb-4" />
<Skeleton className="h-48 w-full" />
</div>
)
}
if (isError) {
return (
<div className="rounded-lg border border-red-700/50 bg-red-900/20 p-4" data-testid="alerts-list">
<p className="text-sm text-red-300">
{t('security.crowdsec.dashboard.alertsError', 'Failed to load alerts.')}
</p>
</div>
)
}
const alerts = data?.alerts ?? []
if (!alerts.length && page === 0) {
return (
<div
className="rounded-lg border border-gray-700 bg-gray-900 p-4 text-center text-gray-400 py-12"
data-testid="alerts-list"
>
<AlertTriangle className="mx-auto h-8 w-8 mb-2 text-gray-500" aria-hidden="true" />
<p>{t('security.crowdsec.dashboard.noAlerts', 'No alerts for the selected period.')}</p>
</div>
)
}
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4" data-testid="alerts-list">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">
{t('security.crowdsec.dashboard.recentAlerts', 'Recent Alerts')}
</h3>
<span className="text-xs text-gray-500" aria-live="polite">
{t('security.crowdsec.dashboard.alertsCount', '{{count}} total', { count: data?.total ?? 0 })}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead>
<tr className="border-b border-gray-700">
<th scope="col" className="py-2 px-3 text-gray-400 font-medium">
{t('security.crowdsec.dashboard.colIP', 'IP')}
</th>
<th scope="col" className="py-2 px-3 text-gray-400 font-medium">
{t('security.crowdsec.dashboard.colScenario', 'Scenario')}
</th>
<th scope="col" className="py-2 px-3 text-gray-400 font-medium">
{t('security.crowdsec.dashboard.colTime', 'Time')}
</th>
<th scope="col" className="py-2 px-3 text-gray-400 font-medium">
{t('security.crowdsec.dashboard.colEvents', 'Events')}
</th>
</tr>
</thead>
<tbody>
{alerts.map((alert, i) => (
<tr
key={`${alert.id}-${i}`}
className="border-b border-gray-800 hover:bg-gray-800/50"
>
<td className="py-2 px-3 font-mono text-gray-300 whitespace-nowrap">
{alert.ip}
</td>
<td className="py-2 px-3 text-gray-300 truncate max-w-[200px]" title={alert.scenario}>
{alert.scenario.split('/').pop()}
</td>
<td className="py-2 px-3 text-gray-400 whitespace-nowrap tabular-nums">
<time dateTime={alert.created_at} title={new Date(alert.created_at).toLocaleString()}>
{formatRelativeTime(alert.created_at)}
</time>
</td>
<td className="py-2 px-3 text-gray-400 tabular-nums">
{alert.events_count}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<nav
className="flex items-center justify-between mt-4 pt-3 border-t border-gray-800"
aria-label={t('security.crowdsec.dashboard.alertsPagination', 'Alerts pagination')}
>
<span className="text-xs text-gray-500">
{t('security.crowdsec.dashboard.pageInfo', 'Page {{current}} of {{total}}', {
current: page + 1,
total: totalPages,
})}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={handlePreviousPage}
disabled={page === 0}
className="inline-flex items-center gap-1 rounded-md bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={t('security.crowdsec.dashboard.previousPage', 'Previous page')}
>
<ChevronLeft className="h-3 w-3" aria-hidden="true" />
{t('security.crowdsec.dashboard.previous', 'Previous')}
</button>
<button
type="button"
onClick={handleNextPage}
disabled={page >= totalPages - 1}
className="inline-flex items-center gap-1 rounded-md bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={t('security.crowdsec.dashboard.nextPage', 'Next page')}
>
{t('security.crowdsec.dashboard.next', 'Next')}
<ChevronRight className="h-3 w-3" aria-hidden="true" />
</button>
</div>
</nav>
)}
</div>
)
}
@@ -0,0 +1,110 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Skeleton } from '../ui'
import type { TimelineBucket, TimeRange } from '../../api/crowdsecDashboard'
interface BanTimelineChartProps {
data: TimelineBucket[] | undefined
isLoading: boolean
isError: boolean
range: TimeRange
}
const BAN_COLOR = '#3b82f6'
const CAPTCHA_COLOR = '#f59e0b'
function formatTick(value: string, range: TimeRange): string {
const d = new Date(value)
if (range === '1h' || range === '6h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
if (range === '24h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
export function BanTimelineChart({ data, isLoading, isError, range }: BanTimelineChartProps) {
const { t } = useTranslation()
const chartData = useMemo(() => {
if (!data) return []
return data.map((b) => ({
time: b.timestamp,
bans: b.bans,
captchas: b.captchas,
}))
}, [data])
if (isLoading) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<Skeleton className="h-4 w-40 mb-4" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (isError) {
return (
<div className="rounded-lg border border-red-700/50 bg-red-900/20 p-4">
<p className="text-sm text-red-300">{t('security.crowdsec.dashboard.timelineError', 'Failed to load timeline data.')}</p>
</div>
)
}
if (!chartData.length) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4 text-center text-gray-400 py-12">
<p>{t('security.crowdsec.dashboard.noTimelineData', 'No decision data for the selected period.')}</p>
</div>
)
}
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-4">
{t('security.crowdsec.dashboard.decisionTimeline', 'Decision Timeline')}
</h3>
<div
role="img"
aria-label={t('security.crowdsec.dashboard.timelineChartLabel', 'Area chart showing bans and captchas over time')}
>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="time"
tickFormatter={(v: string) => formatTick(v, range)}
tick={{ fill: '#9ca3af', fontSize: 12 }}
stroke="#4b5563"
/>
<YAxis tick={{ fill: '#9ca3af', fontSize: 12 }} stroke="#4b5563" allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: 8 }}
labelStyle={{ color: '#d1d5db' }}
labelFormatter={(v) => new Date(String(v)).toLocaleString()}
/>
<Area
type="monotone"
dataKey="bans"
name={t('security.crowdsec.dashboard.bans', 'Bans')}
stroke={BAN_COLOR}
fill={BAN_COLOR}
fillOpacity={0.3}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="captchas"
name={t('security.crowdsec.dashboard.captchas', 'Captchas')}
stroke={CAPTCHA_COLOR}
fill={CAPTCHA_COLOR}
fillOpacity={0.2}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)
}
@@ -0,0 +1,100 @@
import { useQueryClient } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActiveDecisionsTable } from './ActiveDecisionsTable'
import { AlertsList } from './AlertsList'
import { BanTimelineChart } from './BanTimelineChart'
import { DashboardSummaryCards } from './DashboardSummaryCards'
import { DashboardTimeRangeSelector } from './DashboardTimeRangeSelector'
import { DecisionsExportButton } from './DecisionsExportButton'
import { ScenarioBreakdownChart } from './ScenarioBreakdownChart'
import { TopAttackingIPsChart } from './TopAttackingIPsChart'
import {
useDashboardSummary,
useDashboardTimeline,
useDashboardTopIPs,
useDashboardScenarios,
} from '../../hooks/useCrowdsecDashboard'
// NOTE: Notification enrichment skipped — no frontend notification dispatch
// system exists. Gotify/webhook notifications are handled server-side in
// backend/internal/services/.
import type { TimeRange } from '../../api/crowdsecDashboard'
export function CrowdSecDashboard() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [range, setRange] = useState<TimeRange>('24h')
const summary = useDashboardSummary(range)
const timeline = useDashboardTimeline(range)
const topIPs = useDashboardTopIPs(range)
const scenarios = useDashboardScenarios(range)
const isAnyLoading = summary.isLoading || timeline.isLoading || topIPs.isLoading || scenarios.isLoading
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['crowdsec-dashboard'] })
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<DashboardTimeRangeSelector value={range} onChange={setRange} />
<div className="flex items-center gap-2">
<DecisionsExportButton range={range} />
<button
type="button"
onClick={handleRefresh}
disabled={isAnyLoading}
className="inline-flex items-center gap-2 rounded-md bg-gray-800 px-3 py-2 text-sm text-gray-300 hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={t('security.crowdsec.dashboard.refresh', 'Refresh dashboard data')}
>
<RefreshCw className={`h-4 w-4 ${isAnyLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
{t('security.crowdsec.dashboard.refresh', 'Refresh')}
</button>
</div>
</div>
<DashboardSummaryCards
data={summary.data}
isLoading={summary.isLoading}
isError={summary.isError}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<BanTimelineChart
data={timeline.data?.buckets}
isLoading={timeline.isLoading}
isError={timeline.isError}
range={range}
/>
<TopAttackingIPsChart
data={topIPs.data?.ips}
isLoading={topIPs.isLoading}
isError={topIPs.isError}
/>
</div>
<ScenarioBreakdownChart
data={scenarios.data?.scenarios}
isLoading={scenarios.isLoading}
isError={scenarios.isError}
/>
{/* TODO: Replace useDashboardTopIPs with a dedicated useActiveDecisions hook backed by /v1/decisions once endpoints are available */}
<ActiveDecisionsTable
data={topIPs.data?.ips}
isLoading={topIPs.isLoading}
isError={topIPs.isError}
/>
<AlertsList range={range} />
</div>
)
}
export default CrowdSecDashboard
@@ -0,0 +1,98 @@
import { Activity, Shield, ShieldAlert, Users } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '../ui'
import type { DashboardSummary } from '../../api/crowdsecDashboard'
interface DashboardSummaryCardsProps {
data: DashboardSummary | undefined
isLoading: boolean
isError: boolean
}
export function DashboardSummaryCards({ data, isLoading, isError }: DashboardSummaryCardsProps) {
const { t } = useTranslation()
if (isLoading) {
return (
<div data-testid="dashboard-summary-cards" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border border-gray-700 bg-gray-900 p-4 space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-16" />
<Skeleton className="h-3 w-32" />
</div>
))}
</div>
)
}
if (isError || !data) {
return (
<div data-testid="dashboard-summary-cards" className="rounded-lg border border-red-700/50 bg-red-900/20 p-4">
<p className="text-sm text-red-300">{t('security.crowdsec.dashboard.summaryError', 'Failed to load summary data.')}</p>
</div>
)
}
const trendLabel = data.decisions_trend > 0
? `+${data.decisions_trend.toFixed(1)}%`
: data.decisions_trend < 0
? `${data.decisions_trend.toFixed(1)}%`
: '0%'
const trendColor = data.decisions_trend > 0
? 'text-red-400'
: data.decisions_trend < 0
? 'text-green-400'
: 'text-gray-400'
const cards = [
{
title: t('security.crowdsec.dashboard.totalDecisions', 'Total Decisions'),
value: data.total_decisions.toLocaleString(),
icon: Activity,
subtitle: trendLabel,
subtitleColor: trendColor,
},
{
title: t('security.crowdsec.dashboard.activeDecisions', 'Active Decisions'),
value: data.active_decisions === -1 ? 'N/A' : data.active_decisions.toLocaleString(),
icon: ShieldAlert,
subtitle: data.active_decisions === -1
? t('security.crowdsec.dashboard.lapiUnavailable', 'LAPI unavailable')
: t('security.crowdsec.dashboard.currentlyEnforced', 'Currently enforced'),
subtitleColor: data.active_decisions === -1 ? 'text-yellow-400' : 'text-gray-400',
},
{
title: t('security.crowdsec.dashboard.uniqueIPs', 'Unique IPs'),
value: data.unique_ips.toLocaleString(),
icon: Users,
subtitle: t('security.crowdsec.dashboard.distinctAttackers', 'Distinct attackers'),
subtitleColor: 'text-gray-400',
},
{
title: t('security.crowdsec.dashboard.topScenario', 'Top Scenario'),
value: data.top_scenario ? data.top_scenario.split('/').pop() ?? data.top_scenario : '—',
icon: Shield,
subtitle: data.top_scenario || t('security.crowdsec.dashboard.noData', 'No data'),
subtitleColor: 'text-gray-400',
},
]
return (
<div data-testid="dashboard-summary-cards" aria-live="polite" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card) => (
<div key={card.title} className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400">{card.title}</span>
<card.icon className="h-4 w-4 text-gray-500" aria-hidden="true" />
</div>
<p className="text-2xl font-bold text-white">{card.value}</p>
<p className={`text-xs mt-1 ${card.subtitleColor}`}>{card.subtitle}</p>
</div>
))}
</div>
)
}
@@ -0,0 +1,83 @@
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { TimeRange } from '../../api/crowdsecDashboard'
interface DashboardTimeRangeSelectorProps {
value: TimeRange
onChange: (range: TimeRange) => void
}
const RANGES: TimeRange[] = ['1h', '6h', '24h', '7d', '30d']
const RANGE_LABELS: Record<TimeRange, string> = {
'1h': '1H',
'6h': '6H',
'24h': '24H',
'7d': '7D',
'30d': '30D',
}
export function DashboardTimeRangeSelector({ value, onChange }: DashboardTimeRangeSelectorProps) {
const { t } = useTranslation()
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([])
const selectedIndex = RANGES.indexOf(value)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>) => {
let nextIndex: number
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
nextIndex = (selectedIndex + 1) % RANGES.length
break
case 'ArrowLeft':
case 'ArrowUp':
nextIndex = (selectedIndex - 1 + RANGES.length) % RANGES.length
break
case 'Home':
nextIndex = 0
break
case 'End':
nextIndex = RANGES.length - 1
break
default:
return
}
e.preventDefault()
onChange(RANGES[nextIndex])
buttonRefs.current[nextIndex]?.focus()
},
[selectedIndex, onChange],
)
return (
<div
role="radiogroup"
aria-label={t('security.crowdsec.dashboard.timeRange', 'Time range')}
className="inline-flex rounded-lg border border-gray-700 bg-gray-900 p-1"
>
{RANGES.map((range, i) => (
<button
key={range}
ref={(el) => { buttonRefs.current[i] = el }}
role="radio"
aria-checked={value === range}
tabIndex={value === range ? 0 : -1}
onClick={() => onChange(range)}
onKeyDown={handleKeyDown}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
value === range
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
{RANGE_LABELS[range]}
</button>
))}
</div>
)
}
@@ -0,0 +1,185 @@
import { Download, ChevronDown, FileJson, FileSpreadsheet, Loader2 } from 'lucide-react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { exportDecisions, type TimeRange } from '../../api/crowdsecDashboard'
interface DecisionsExportButtonProps {
range: TimeRange
}
type ExportFormat = 'csv' | 'json'
export function DecisionsExportButton({ range }: DecisionsExportButtonProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([])
const [focusedIndex, setFocusedIndex] = useState(-1)
const closeMenu = useCallback(() => {
setIsOpen(false)
setFocusedIndex(-1)
buttonRef.current?.focus()
}, [])
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeMenu()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, closeMenu])
useEffect(() => {
if (isOpen && focusedIndex >= 0 && menuItemsRef.current[focusedIndex]) {
menuItemsRef.current[focusedIndex]?.focus()
}
}, [focusedIndex, isOpen])
const handleToggle = () => {
if (isOpen) {
closeMenu()
} else {
setIsOpen(true)
setFocusedIndex(0)
setError(null)
}
}
const handleMenuKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setFocusedIndex((i) => Math.min(i + 1, 1))
break
case 'ArrowUp':
e.preventDefault()
setFocusedIndex((i) => Math.max(i - 1, 0))
break
case 'Escape':
e.preventDefault()
closeMenu()
break
case 'Tab':
closeMenu()
break
}
}
const handleButtonKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
setIsOpen(true)
setFocusedIndex(0)
setError(null)
}
}
const handleExport = async (format: ExportFormat) => {
closeMenu()
setIsExporting(true)
setError(null)
try {
const blob = await exportDecisions(format, range)
if (!blob || blob.size === 0) {
throw new Error('Empty response')
}
const timestamp = new Date().toISOString().slice(0, 10)
const filename = `crowdsec-decisions-${timestamp}.${format}`
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch {
setError(t('security.crowdsec.dashboard.exportError', 'Export failed. Please try again.'))
} finally {
setIsExporting(false)
}
}
const menuItems: { format: ExportFormat; label: string; icon: typeof FileJson }[] = [
{
format: 'csv',
label: t('security.crowdsec.dashboard.exportCSV', 'Export as CSV'),
icon: FileSpreadsheet,
},
{
format: 'json',
label: t('security.crowdsec.dashboard.exportJSON', 'Export as JSON'),
icon: FileJson,
},
]
return (
<div className="relative" ref={menuRef} data-testid="decisions-export">
<button
ref={buttonRef}
type="button"
onClick={handleToggle}
onKeyDown={handleButtonKeyDown}
disabled={isExporting}
className="inline-flex items-center gap-2 rounded-md bg-gray-800 px-3 py-2 text-sm text-gray-300 hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 disabled:opacity-50 disabled:cursor-not-allowed"
aria-haspopup="true"
aria-expanded={isOpen}
aria-controls="export-menu"
aria-label={t('security.crowdsec.dashboard.exportDecisions', 'Export decisions')}
>
{isExporting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Download className="h-4 w-4" aria-hidden="true" />
)}
{t('security.crowdsec.dashboard.export', 'Export')}
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
{isOpen && (
<div
role="menu"
id="export-menu"
tabIndex={-1}
aria-label={t('security.crowdsec.dashboard.exportFormat', 'Export format')}
className="absolute right-0 z-10 mt-1 w-48 rounded-md border border-gray-700 bg-gray-900 shadow-lg"
onKeyDown={handleMenuKeyDown}
>
{menuItems.map((item, index) => {
const Icon = item.icon
return (
<button
key={item.format}
ref={(el) => { menuItemsRef.current[index] = el }}
type="button"
role="menuitem"
tabIndex={focusedIndex === index ? 0 : -1}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800 focus:outline-none focus-visible:bg-gray-800 first:rounded-t-md last:rounded-b-md"
onClick={() => handleExport(item.format)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{item.label}
</button>
)
})}
</div>
)}
{error && (
<p className="absolute right-0 mt-1 text-xs text-red-400 whitespace-nowrap" role="alert">
{error}
</p>
)}
</div>
)
}
@@ -0,0 +1,107 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
import { Skeleton } from '../ui'
import type { ScenarioEntry } from '../../api/crowdsecDashboard'
interface ScenarioBreakdownChartProps {
data: ScenarioEntry[] | undefined
isLoading: boolean
isError: boolean
}
const SCENARIO_COLORS = ['#6366f1', '#10b981', '#f43f5e', '#06b6d4', '#64748b', '#f59e0b', '#8b5cf6', '#ec4899']
export function ScenarioBreakdownChart({ data, isLoading, isError }: ScenarioBreakdownChartProps) {
const { t } = useTranslation()
const chartData = useMemo(() => {
if (!data) return []
return data.map((s) => ({
name: s.name.split('/').pop() ?? s.name,
fullName: s.name,
count: s.count,
percentage: s.percentage,
}))
}, [data])
if (isLoading) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<Skeleton className="h-4 w-48 mb-4" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (isError) {
return (
<div className="rounded-lg border border-red-700/50 bg-red-900/20 p-4">
<p className="text-sm text-red-300">{t('security.crowdsec.dashboard.scenariosError', 'Failed to load scenario data.')}</p>
</div>
)
}
if (!chartData.length) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4 text-center text-gray-400 py-12">
<p>{t('security.crowdsec.dashboard.noScenarios', 'No scenario data for the selected period.')}</p>
</div>
)
}
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-4">
{t('security.crowdsec.dashboard.scenarioBreakdown', 'Scenario Breakdown')}
</h3>
<div className="flex flex-col lg:flex-row items-center gap-4">
<div
role="img"
aria-label={t('security.crowdsec.dashboard.scenarioChartLabel', 'Donut chart showing distribution of scenarios by decision count')}
className="flex-shrink-0"
>
<ResponsiveContainer width={240} height={240}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
dataKey="count"
nameKey="name"
paddingAngle={2}
>
{chartData.map((entry, i) => (
<Cell key={entry.fullName} fill={SCENARIO_COLORS[i % SCENARIO_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: 8 }}
labelStyle={{ color: '#d1d5db' }}
formatter={(value, name) => [`${value} (${chartData.find(d => d.name === String(name))?.percentage.toFixed(1)}%)`, String(name)]}
/>
</PieChart>
</ResponsiveContainer>
</div>
<ul className="flex-1 space-y-2 text-sm w-full" aria-label={t('security.crowdsec.dashboard.scenarioLegend', 'Scenario legend')}>
{chartData.map((entry, i) => (
<li key={entry.fullName} className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: SCENARIO_COLORS[i % SCENARIO_COLORS.length] }}
aria-hidden="true"
/>
<span className="text-gray-300 truncate" title={entry.fullName}>{entry.name}</span>
<span className="ml-auto text-gray-500 tabular-nums">{entry.count}</span>
<span className="text-gray-600 w-12 text-right tabular-nums">{entry.percentage.toFixed(1)}%</span>
</li>
))}
</ul>
</div>
</div>
)
}
@@ -0,0 +1,88 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { Skeleton } from '../ui'
import type { TopIP } from '../../api/crowdsecDashboard'
interface TopAttackingIPsChartProps {
data: TopIP[] | undefined
isLoading: boolean
isError: boolean
}
const BAR_COLOR = '#6366f1'
export function TopAttackingIPsChart({ data, isLoading, isError }: TopAttackingIPsChartProps) {
const { t } = useTranslation()
const chartData = useMemo(() => {
if (!data) return []
return data.slice(0, 10).map((ip) => ({
ip: ip.ip,
decisions: ip.count,
}))
}, [data])
if (isLoading) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<Skeleton className="h-4 w-40 mb-4" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (isError) {
return (
<div className="rounded-lg border border-red-700/50 bg-red-900/20 p-4">
<p className="text-sm text-red-300">{t('security.crowdsec.dashboard.topIPsError', 'Failed to load top IPs data.')}</p>
</div>
)
}
if (!chartData.length) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4 text-center text-gray-400 py-12">
<p>{t('security.crowdsec.dashboard.noTopIPs', 'No attacking IPs in the selected period.')}</p>
</div>
)
}
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-4">
{t('security.crowdsec.dashboard.topAttackingIPs', 'Top Attacking IPs')}
</h3>
<div
role="img"
aria-label={t('security.crowdsec.dashboard.topIPsChartLabel', 'Horizontal bar chart showing top attacking IPs by decision count')}
>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} layout="vertical" margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" horizontal={false} />
<XAxis type="number" tick={{ fill: '#9ca3af', fontSize: 12 }} stroke="#4b5563" allowDecimals={false} />
<YAxis
dataKey="ip"
type="category"
tick={{ fill: '#9ca3af', fontSize: 11 }}
stroke="#4b5563"
width={120}
/>
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: 8 }}
labelStyle={{ color: '#d1d5db' }}
/>
<Bar
dataKey="decisions"
name={t('security.crowdsec.dashboard.decisions', 'Decisions')}
fill={BAR_COLOR}
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}