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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user