Files
Charon/docs/plans/current_spec.md
GitHub Actions 1fe69c2a15 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.
2026-03-25 17:19:15 +00:00

48 KiB
Raw Blame History

CrowdSec Dashboard Integration — Implementation Specification

Issue: #26 Version: 1.1 Status: Draft — Post-Supervisor Review


1. Executive Summary

What We're Building

A metrics and analytics dashboard for CrowdSec within Charon's existing Security section. This adds visualization, aggregation, and export capabilities to the CrowdSec module — surfacing data that today is only available via CLI or raw LAPI queries.

Why

CrowdSec is already operationally integrated (start/stop, config, bouncer registration, decisions, console enrollment). What's missing is visibility: users cannot see attack trends, scenario breakdowns, ban history, or top offenders without SSH-ing into the container and running cscli commands. A dashboard closes this gap and makes Charon's security posture observable from the UI.

Success Metrics

Metric Target
Issue #26 checklist tasks complete 8/8
New backend aggregation endpoints covered by unit tests ≥ 85% line coverage
New frontend components covered by Vitest unit tests ≥ 85% line coverage
E2E tests for dashboard page passing All browsers (Firefox, Chromium, WebKit)
Dashboard page initial load time < 2 seconds on cached data
No new CRITICAL/HIGH security findings GORM scanner, CodeQL, Trivy

2. Requirements (EARS Notation)

R1: Metrics Dashboard Tab

WHEN the user navigates to /security/crowdsec, THE SYSTEM SHALL display a "Dashboard" tab alongside the existing configuration interface, showing summary statistics (total bans, active bans, unique IPs, top scenario).

R2: Active Bans with Reasons

WHEN the Dashboard tab is active, THE SYSTEM SHALL display a table of currently active decisions including IP, scenario/reason, duration, type (ban/captcha), origin, and time remaining.

R3: Scenarios Triggered

WHEN the Dashboard tab is active, THE SYSTEM SHALL display a breakdown of CrowdSec scenarios that have triggered decisions, with counts per scenario, sourced from LAPI alerts.

R4: Ban History Timeline

WHEN the Dashboard tab is active, THE SYSTEM SHALL display a time-series chart showing ban/unban events over a configurable time range (default: last 24 hours; options: 1h, 6h, 24h, 7d, 30d).

R5: Top Attacking IPs

WHEN the Dashboard tab is active, THE SYSTEM SHALL display a horizontal bar chart of the top 10 IP addresses by number of decisions, within the selected time range.

R6: Attack Type Breakdown

WHEN the Dashboard tab is active, THE SYSTEM SHALL display a pie/donut chart breaking down decisions by scenario type (e.g., crowdsecurity/http-probing, crowdsecurity/ssh-bf).

R7: Alert Notifications

WHEN a new CrowdSec decision is created, THE SYSTEM SHALL dispatch a notification via the existing notification provider system (Gotify/webhook) if the user has enabled CrowdSec decision notifications. (Partially implemented — this PR enriches the payload with scenario and structured data.)

R8: Ban Export

WHEN the user clicks the "Export" button on the Dashboard tab, THE SYSTEM SHALL export the current decisions list as CSV or JSON, with the user's choice of format and time range.

Non-Functional Requirements

  • NF1: Aggregation queries SHALL complete within 200ms for tables up to 100k rows.
  • NF2: Dashboard data SHALL be cached server-side with a 30-second TTL.
  • NF3: All new endpoints SHALL require admin authentication.
  • NF4: No LAPI keys SHALL appear in API responses (except the existing GetBouncerKey endpoint).
  • NF5: The dashboard SHALL be accessible: keyboard navigable, screen-reader compatible, WCAG 2.2 AA contrast.

3. Architecture & Design

3.1 Data Sources

The dashboard draws from two data sources:

Source Data Access Method
CrowdSec LAPI (/v1/decisions, /v1/alerts) Active decisions, alerts with scenarios, timestamps, IP addresses HTTP via network.NewInternalServiceHTTPClient with SSRF-safe URL validation
SQLite security_decisions table Historical decisions persisted by Charon's middleware GORM queries with aggregation

3.2 New Backend Endpoints

All endpoints are prefixed with /api/v1/admin/crowdsec/ and require admin auth.

GET  /admin/crowdsec/dashboard/summary        → DashboardSummary
GET  /admin/crowdsec/dashboard/timeline        → DashboardTimeline
GET  /admin/crowdsec/dashboard/top-ips         → DashboardTopIPs
GET  /admin/crowdsec/dashboard/scenarios       → DashboardScenarios
GET  /admin/crowdsec/alerts                    → ListAlerts (LAPI wrapper)
GET  /admin/crowdsec/decisions/export          → ExportDecisions (CSV/JSON)

3.3 SecurityDecision Model Enrichment

The existing SecurityDecision model needs new indexed fields to support scenario-based aggregation without requiring LAPI for historical queries:

// backend/internal/models/security_decision.go
type SecurityDecision struct {
    ID        uint      `json:"-" gorm:"primaryKey"`
    UUID      string    `json:"uuid" gorm:"uniqueIndex"`
    Source    string    `json:"source" gorm:"index"`
    Action    string    `json:"action" gorm:"index"`
    IP        string    `json:"ip" gorm:"index"`
    Host      string    `json:"host" gorm:"index"`
    RuleID    string    `json:"rule_id" gorm:"index"`
    Details   string    `json:"details" gorm:"type:text"`
    CreatedAt time.Time `json:"created_at" gorm:"index"`

    // NEW FIELDS (PR-1)
    Scenario  string    `json:"scenario" gorm:"index"`    // e.g., "crowdsecurity/http-probing"
    Country   string    `json:"country" gorm:"index;size:2"` // ISO 3166-1 alpha-2 (best-effort, see below)
    ExpiresAt time.Time `json:"expires_at" gorm:"index"`  // When this decision expires
}

Country field population (I3 clarification): The Country field is populated on a best-effort basis. CrowdSec LAPI alerts include a source.cn field (ISO 3166-1 alpha-2 country code, e.g., "CN", "RU", "US") when GeoIP data is available in the CrowdSec hub enrichment. No additional GeoIP lookup by Charon is required. When source.cn is absent (e.g., local IPs, LAPI unavailable, manual bans), the field is stored as an empty string "". The frontend treats empty-string country values as "Unknown" in display.

Migration: GORM AutoMigrate will add columns non-destructively. No manual migration needed. Existing rows will have empty Scenario/Country/ExpiresAt fields.

Composite Indexes: In addition to the single-column indexes defined via struct tags, the following composite indexes are required for performant aggregation queries on 100k+ row tables. Define them via GORM compositeIndex tags or a custom Migrator.CreateIndex() call in the model's AfterAutoMigrate hook:

Composite Index Name Columns Covers
idx_sd_source_created (source, created_at) All time-range filtered queries: summary counts, timeline bucketing
idx_sd_source_scenario_created (source, scenario, created_at) Scenario breakdown, top-scenario ranking
idx_sd_source_ip_created (source, ip, created_at) Top-IPs ranking, unique IP counts

GORM definition (compositeIndex tag approach):

type SecurityDecision struct {
    // ...
    Source    string    `json:"source" gorm:"index;compositeIndex:idx_sd_source_created;compositeIndex:idx_sd_source_scenario_created;compositeIndex:idx_sd_source_ip_created"`
    Scenario  string   `json:"scenario" gorm:"index;compositeIndex:idx_sd_source_scenario_created"`
    IP        string   `json:"ip" gorm:"index;compositeIndex:idx_sd_source_ip_created"`
    CreatedAt time.Time `json:"created_at" gorm:"index;compositeIndex:idx_sd_source_created,sort:desc;compositeIndex:idx_sd_source_scenario_created,sort:desc;compositeIndex:idx_sd_source_ip_created,sort:desc"`
    // ...
}

Alternatively, define via explicit Migrator calls in a post-migration hook if tag complexity is too high:

db.Migrator().CreateIndex(&SecurityDecision{}, "idx_sd_source_created")

3.4 Data Flow

┌─────────────┐     ┌──────────────────┐     ┌───────────────────┐
│   React UI  │────▶│  Go Backend API  │────▶│  CrowdSec LAPI    │
│  Dashboard  │     │  /dashboard/*    │     │  /v1/decisions     │
│  Components │◀────│  + Cache Layer   │◀────│  /v1/alerts        │
└─────────────┘     │                  │     └───────────────────┘
                    │  Aggregation     │
                    │  Queries         │────▶┌───────────────────┐
                    │                  │     │  SQLite DB         │
                    │                  │◀────│  security_decisions│
                    └──────────────────┘     └───────────────────┘

3.5 Caching Strategy

A simple in-memory cache with TTL per endpoint. The cache instance is owned by CrowdsecHandler:

// backend/internal/api/handlers/crowdsec_handler.go — add field to existing struct
type CrowdsecHandler struct {
    // ... existing fields (DB, Executor, CmdExec, BinPath, etc.)
    dashCache *dashboardCache // Dashboard analytics cache (initialized in constructor)
}

dashCache is initialized in NewCrowdsecHandler() (or the factory function that creates the handler). BanIP and UnbanIP call h.dashCache.Invalidate("dashboard") after successful operations to ensure stale data is not served.

// backend/internal/api/handlers/crowdsec_dashboard_cache.go
type dashboardCache struct {
    mu      sync.RWMutex
    entries map[string]*cacheEntry
}

type cacheEntry struct {
    data      interface{}
    expiresAt time.Time
}
Endpoint TTL Rationale
/dashboard/summary 30s High-level counters, frequent access
/dashboard/timeline 60s Aggregated time-series, moderate computation
/dashboard/top-ips 60s Aggregated ranking
/dashboard/scenarios 60s Scenario grouping
/alerts 30s LAPI-sourced, changes frequently

Cache is invalidated on any BanIP or UnbanIP call.

3.6 Frontend Component Tree

CrowdSecConfig.tsx (existing — add tab navigation)
├── Tab: Configuration (existing content, unchanged)
└── Tab: Dashboard (NEW)
    └── CrowdSecDashboard.tsx
        ├── DashboardSummaryCards.tsx      (4 stat cards)
        ├── BanTimelineChart.tsx           (Recharts AreaChart)
        ├── TopAttackingIPsChart.tsx       (Recharts BarChart horizontal)
        ├── ScenarioBreakdownChart.tsx     (Recharts PieChart)
        ├── ActiveDecisionsTable.tsx       (table with sort/filter)
        ├── AlertsList.tsx                 (LAPI alerts feed)
        ├── DashboardTimeRangeSelector.tsx (1h/6h/24h/7d/30d toggle)
        └── DecisionsExportButton.tsx      (CSV/JSON export)

3.7 Charting Library: Recharts

Decision: Add recharts as a frontend dependency.

Criterion Recharts Chart.js/react-chartjs-2 Nivo
React integration Native (built on React components) Wrapper around imperative API Native
Bundle size (tree-shaken) ~45 KB gzipped (only imported charts) ~65 KB gzipped ~80+ KB
TypeScript support Built-in @types package Built-in
D3-based Yes (composable) No (Canvas-based) Yes
Accessibility SVG-based, supports ARIA labels Canvas (limited a11y) SVG, good a11y
Tailwind compatibility SVG renders, style via props Canvas, no CSS styling SVG renders
Maintenance Active, 24k+ stars, MIT Active Active
SSR/SSG Works Requires client-only Works

Rationale: Recharts is React-native (no imperative bridge), renders SVG (accessible, inspectable, screen-reader friendly), tree-shakeable, and has the best Tailwind CSS compatibility. Bundle impact is minimal since only the chart types we use are imported.

Install: npm install recharts in frontend/.

3.8 Build & Config File Changes

frontend/package.json — add "recharts": "^2.15.0" to dependencies.

Dockerfile — No changes needed. npm ci at line 113 will install Recharts from the updated package-lock.json.

.gitignore — No changes needed. node_modules/ is already ignored.

codecov.yml — No changes needed. Frontend coverage flags already cover frontend/src/**.

.dockerignore — No changes needed. Build context already includes frontend/.


4. API Specification

4.1 GET /admin/crowdsec/dashboard/summary

Returns aggregate counts for the dashboard summary cards.

Query Parameters:

Param Type Default Description
range string 24h Time range: 1h, 6h, 24h, 7d, 30d

Response (200):

{
  "total_decisions": 1247,
  "active_decisions": 23,
  "unique_ips": 891,
  "top_scenario": "crowdsecurity/http-probing",
  "decisions_trend": 12.5,
  "range": "24h",
  "cached": true,
  "generated_at": "2026-03-25T10:30:00Z"
}

decisions_trend calculation: Percentage change vs. the previous period of equal length. For a 24h range: compare total decisions in the last 24h against the total decisions in the 24h before that. Formula: ((current - previous) / previous) * 100. If previous = 0, return 0.0 (no trend data). If current = 0 and previous > 0, return -100.0. Value is a float rounded to 1 decimal place. Positive = increase, negative = decrease.

Backend Implementation:

// File: backend/internal/api/handlers/crowdsec_dashboard.go
// Function: func (h *CrowdsecHandler) DashboardSummary(c *gin.Context)

// HYBRID APPROACH (B7): active_decisions comes from LAPI; historical metrics from SQLite.
//
// Existing Cerberus middleware callers of SecurityService.LogDecision() do not
// populate ExpiresAt. Rather than modifying all middleware callers in PR-1, we
// use the authoritative LAPI source for active decision counts and SQLite for
// historical aggregations only.
//
// From CrowdSec LAPI (real-time, authoritative):
//   GET /v1/decisions  → len(decisions) = active_decisions
//
// From SQLite (historical aggregation):
//   SELECT COUNT(*) as total FROM security_decisions WHERE created_at >= ? AND source = 'crowdsec'
//   SELECT COUNT(DISTINCT ip) as unique_ips FROM security_decisions WHERE created_at >= ? AND source = 'crowdsec'
//   SELECT scenario, COUNT(*) as cnt FROM security_decisions WHERE created_at >= ? AND source = 'crowdsec' GROUP BY scenario ORDER BY cnt DESC LIMIT 1
//
// Fallback: If LAPI is unreachable, active_decisions = -1 (frontend shows "N/A").
// The ExpiresAt field is still added to the model for future enrichment when
// Cerberus middleware callers are updated in a subsequent PR.

4.2 GET /admin/crowdsec/dashboard/timeline

Returns time-bucketed decision counts for the timeline chart.

Query Parameters:

Param Type Default Description
range string 24h Time range
interval string auto Bucket interval: 5m, 1h, 1d (auto-selected based on range if omitted)

Auto-interval mapping:

Range Default Interval Max Buckets
1h 5m 12
6h 15m 24
24h 1h 24
7d 6h 28
30d 1d 30

Response (200):

{
  "buckets": [
    { "timestamp": "2026-03-25T00:00:00Z", "bans": 5, "captchas": 1 },
    { "timestamp": "2026-03-25T01:00:00Z", "bans": 12, "captchas": 0 }
  ],
  "range": "24h",
  "interval": "1h",
  "cached": true
}

Backend Implementation:

// File: backend/internal/api/handlers/crowdsec_dashboard.go
// Function: func (h *CrowdsecHandler) DashboardTimeline(c *gin.Context)

// SQLite time bucketing via strftime for all 5 intervals:
//
// 5m:  strftime('%Y-%m-%dT%H:', created_at) ||
//        printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5)
//
// 15m: strftime('%Y-%m-%dT%H:', created_at) ||
//        printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 15) * 15)
//
// 1h:  strftime('%Y-%m-%dT%H:00:00Z', created_at)
//
// 6h:  strftime('%Y-%m-%dT', created_at) ||
//        printf('%02d:00:00Z', (CAST(strftime('%H', created_at) AS INTEGER) / 6) * 6)
//
// 1d:  strftime('%Y-%m-%dT00:00:00Z', created_at)
//
// Example (1h bucket — default for 24h range):
// SELECT strftime('%Y-%m-%dT%H:00:00Z', created_at) as bucket,
//        SUM(CASE WHEN action = 'block' THEN 1 ELSE 0 END) as bans,
//        SUM(CASE WHEN action = 'challenge' THEN 1 ELSE 0 END) as captchas
// FROM security_decisions
// WHERE created_at >= ? AND source = 'crowdsec'
// GROUP BY bucket ORDER BY bucket ASC

The intervalToStrftime(interval string) string helper function maps the interval parameter to the appropriate SQLite strftime expression. The 5-minute and 6-hour roundings use integer division to snap to the nearest bucket boundary.

4.3 GET /admin/crowdsec/dashboard/top-ips

Returns top attacking IPs ranked by decision count.

Query Parameters:

Param Type Default Description
range string 24h Time range
limit int 10 Max results (capped at 50)

Response (200):

{
  "ips": [
    { "ip": "203.0.113.42", "count": 47, "last_seen": "2026-03-25T09:15:00Z", "country": "CN" },
    { "ip": "198.51.100.7", "count": 23, "last_seen": "2026-03-25T08:30:00Z", "country": "RU" }
  ],
  "range": "24h",
  "cached": true
}

Design Decision (B3): top_scenario was removed from the per-IP response. Computing the most frequent scenario per IP requires a correlated subquery or two-pass query, adding significant complexity for marginal UX value. The scenario breakdown chart (Section 4.4) already provides scenario visibility. If per-IP scenario data is needed later, it can be added as a detail view.

Backend Implementation:

// File: backend/internal/api/handlers/crowdsec_dashboard.go
// Function: func (h *CrowdsecHandler) DashboardTopIPs(c *gin.Context)

// SELECT ip, COUNT(*) as count, MAX(created_at) as last_seen, country
// FROM security_decisions
// WHERE created_at >= ? AND source = 'crowdsec'
// GROUP BY ip ORDER BY count DESC LIMIT ?

4.4 GET /admin/crowdsec/dashboard/scenarios

Returns scenario breakdown with counts.

Query Parameters:

Param Type Default Description
range string 24h Time range

Response (200):

{
  "scenarios": [
    { "name": "crowdsecurity/http-probing", "count": 89, "percentage": 42.3 },
    { "name": "crowdsecurity/ssh-bf", "count": 45, "percentage": 21.4 },
    { "name": "crowdsecurity/http-bad-user-agent", "count": 32, "percentage": 15.2 }
  ],
  "total": 210,
  "range": "24h",
  "cached": true
}

4.5 GET /admin/crowdsec/alerts

Wraps the LAPI /v1/alerts endpoint, providing alert data without exposing LAPI keys.

Query Parameters:

Param Type Default Description
range string 24h Time range filter
scenario string (all) Filter by scenario name
limit int 50 Max results (capped at 200)
offset int 0 Pagination offset

Response (200):

{
  "alerts": [
    {
      "id": 1234,
      "scenario": "crowdsecurity/http-probing",
      "ip": "203.0.113.42",
      "message": "Ip 203.0.113.42 performed 'crowdsecurity/http-probing' ...",
      "events_count": 15,
      "start_at": "2026-03-25T09:10:00Z",
      "stop_at": "2026-03-25T09:15:00Z",
      "created_at": "2026-03-25T09:15:30Z"
    }
  ],
  "total": 247,
  "source": "lapi",
  "cached": true
}

Backend Implementation:

// File: backend/internal/api/handlers/crowdsec_dashboard.go
// Function: func (h *CrowdsecHandler) ListAlerts(c *gin.Context)

// Uses same SSRF-safe HTTP client pattern as GetLAPIDecisions:
// endpoint := baseURL.ResolveReference(&url.URL{Path: "/v1/alerts"})
// req.Header.Set("X-Api-Key", apiKey)
// Falls back to `cscli alerts list -o json` on LAPI failure.

4.6 GET /admin/crowdsec/decisions/export

Exports decisions as downloadable CSV or JSON.

Query Parameters:

Param Type Default Description
format string csv Export format: csv or json
range string 24h Time range
source string all Filter: crowdsec, waf, ratelimit, manual, all

Response: File download with appropriate Content-Type and Content-Disposition headers.

  • CSV: text/csv; charset=utf-8 with filename crowdsec-decisions-{timestamp}.csv
  • JSON: application/json with filename crowdsec-decisions-{timestamp}.json

CSV Columns: uuid,ip,action,source,scenario,rule_id,host,country,created_at,expires_at

Security — CSV Formula Injection (CWE-1236): CSV field values MUST be sanitized to prevent formula injection. Fields starting with =, +, -, @, \t, or \r SHALL be prefixed with a single quote (') before writing to the CSV output. This applies to all user-influenced fields: ip, scenario, rule_id, host, and details. Reference: OWASP CSV Injection.


5. Frontend Component Design

5.1 New Files

File Purpose
frontend/src/pages/CrowdSecDashboard.tsx Dashboard tab content (orchestrator)
frontend/src/components/crowdsec/DashboardSummaryCards.tsx 4 summary stat cards
frontend/src/components/crowdsec/BanTimelineChart.tsx Area chart for ban timeline
frontend/src/components/crowdsec/TopAttackingIPsChart.tsx Horizontal bar chart
frontend/src/components/crowdsec/ScenarioBreakdownChart.tsx Pie/donut chart
frontend/src/components/crowdsec/ActiveDecisionsTable.tsx Enhanced decisions table
frontend/src/components/crowdsec/AlertsList.tsx LAPI alerts feed
frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx Time range toggle (1h/6h/24h/7d/30d)
frontend/src/components/crowdsec/DecisionsExportButton.tsx Export dropdown (CSV/JSON)
frontend/src/api/crowdsecDashboard.ts API client for dashboard endpoints
frontend/src/hooks/useCrowdsecDashboard.ts React Query hooks for dashboard data

5.2 Integration into Existing Page

CrowdSecConfig.tsx currently renders configuration UI directly. We add tab navigation using Radix UI Tabs (already in package.json as @radix-ui/react-tabs):

// CrowdSecConfig.tsx — modified
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui'
import { CrowdSecDashboard } from './CrowdSecDashboard'

// Inside the return:
<Tabs defaultValue="config" className="w-full">
  <TabsList>
    <TabsTrigger value="config">Configuration</TabsTrigger>
    <TabsTrigger value="dashboard">Dashboard</TabsTrigger>
  </TabsList>
  <TabsContent value="config">
    {/* ... existing configuration content (moved here) ... */}
  </TabsContent>
  <TabsContent value="dashboard">
    <CrowdSecDashboard />
  </TabsContent>
</Tabs>

5.3 API Client

// frontend/src/api/crowdsecDashboard.ts

import client from './client'

export type TimeRange = '1h' | '6h' | '24h' | '7d' | '30d'

export interface DashboardSummary {
  total_decisions: number
  active_decisions: number
  unique_ips: number
  top_scenario: string
  decisions_trend: number
  range: string
  cached: boolean
  generated_at: string
}

export interface TimelineBucket {
  timestamp: string
  bans: number
  captchas: number
}

export interface TimelineData {
  buckets: TimelineBucket[]
  range: string
  interval: string
  cached: boolean
}

export interface TopIP {
  ip: string
  count: number
  last_seen: string
  country: string
}

export interface TopIPsData {
  ips: TopIP[]
  range: string
  cached: boolean
}

export interface ScenarioEntry {
  name: string
  count: number
  percentage: number
}

export interface ScenariosData {
  scenarios: ScenarioEntry[]
  total: number
  range: string
  cached: boolean
}

export interface CrowdSecAlert {
  id: number
  scenario: string
  ip: string
  message: string
  events_count: number
  start_at: string
  stop_at: string
  created_at: string
  duration: string
  type: string
  origin: string
}

export interface AlertsData {
  alerts: CrowdSecAlert[]
  total: number
  source: string
  cached: boolean
}

export async function getDashboardSummary(range: TimeRange): Promise<DashboardSummary> {
  const resp = await client.get('/admin/crowdsec/dashboard/summary', { params: { range } })
  return resp.data
}

export async function getDashboardTimeline(range: TimeRange): Promise<TimelineData> {
  const resp = await client.get('/admin/crowdsec/dashboard/timeline', { params: { range } })
  return resp.data
}

export async function getDashboardTopIPs(range: TimeRange, limit = 10): Promise<TopIPsData> {
  const resp = await client.get('/admin/crowdsec/dashboard/top-ips', { params: { range, limit } })
  return resp.data
}

export async function getDashboardScenarios(range: TimeRange): Promise<ScenariosData> {
  const resp = await client.get('/admin/crowdsec/dashboard/scenarios', { params: { range } })
  return resp.data
}

export async function getAlerts(params: {
  range?: TimeRange; scenario?: string; limit?: number; offset?: number
}): Promise<AlertsData> {
  const resp = await client.get('/admin/crowdsec/alerts', { params })
  return resp.data
}

export async function exportDecisions(
  format: 'csv' | 'json', range: TimeRange, source = 'all'
): Promise<Blob> {
  const resp = await client.get('/admin/crowdsec/decisions/export', {
    params: { format, range, source },
    responseType: 'blob',
  })
  return resp.data
}

5.4 React Query Hooks

// frontend/src/hooks/useCrowdsecDashboard.ts

import { useQuery } from '@tanstack/react-query'
import {
  getDashboardSummary,
  getDashboardTimeline,
  getDashboardTopIPs,
  getDashboardScenarios,
  getAlerts,
  type TimeRange,
} from '../api/crowdsecDashboard'

const STALE_TIME = 30_000 // 30 seconds — matches backend cache TTL

export function useDashboardSummary(range: TimeRange) {
  return useQuery({
    queryKey: ['crowdsec-dashboard-summary', range],
    queryFn: () => getDashboardSummary(range),
    staleTime: STALE_TIME,
  })
}

export function useDashboardTimeline(range: TimeRange) {
  return useQuery({
    queryKey: ['crowdsec-dashboard-timeline', range],
    queryFn: () => getDashboardTimeline(range),
    staleTime: STALE_TIME,
  })
}

export function useDashboardTopIPs(range: TimeRange, limit = 10) {
  return useQuery({
    queryKey: ['crowdsec-dashboard-top-ips', range, limit],
    queryFn: () => getDashboardTopIPs(range, limit),
    staleTime: STALE_TIME,
  })
}

export function useDashboardScenarios(range: TimeRange) {
  return useQuery({
    queryKey: ['crowdsec-dashboard-scenarios', range],
    queryFn: () => getDashboardScenarios(range),
    staleTime: STALE_TIME,
  })
}

export function useAlerts(params: {
  range?: TimeRange; scenario?: string; limit?: number; offset?: number
}) {
  return useQuery({
    queryKey: ['crowdsec-alerts', params],
    queryFn: () => getAlerts(params),
    staleTime: STALE_TIME,
  })
}

5.5 Accessibility Considerations

  • Charts: Each Recharts component gets role="img" and aria-label describing the chart content. Recharts SVG output supports <title> and <desc> elements.
  • Tables: Use semantic <table>, <th>, <td> with proper headers. Sort controls use aria-sort.
  • Time Range Selector: Uses Radix Tabs pattern (roving tabindex, arrow key navigation).
  • Color: All chart colors meet WCAG 2.2 AA contrast (4.5:1 minimum). Patterns/shapes supplement color for colorblind users.
  • Keyboard: All interactive elements (tabs, buttons, table rows) keyboard navigable. Focus indicators visible.
  • Screen Reader: Summary cards use aria-live="polite" for dynamic updates when time range changes.

6. Implementation Phases

Phase 1 / PR-1: Backend Aggregation APIs + Model Enrichment

Scope: Backend only. No frontend changes.

Files to create:

File Description
backend/internal/api/handlers/crowdsec_dashboard.go New handler file with all 6 dashboard/export endpoints
backend/internal/api/handlers/crowdsec_dashboard_cache.go In-memory cache with TTL
backend/internal/api/handlers/crowdsec_dashboard_test.go Unit tests for all new endpoints

Files to modify:

File Change
backend/internal/models/security_decision.go Add Scenario, Country, ExpiresAt fields
backend/internal/api/handlers/crowdsec_handler.go In RegisterRoutes, add 6 new route registrations
backend/internal/api/handlers/crowdsec_handler.go In BanIP, after successful cscli decisions add, call h.Security.LogDecision() to persist a SecurityDecision record with Source: "crowdsec", Action: "block", Scenario: "manual", ExpiresAt computed from the duration parameter, and Country: "" (empty for manual bans). This ensures manual bans appear in dashboard aggregations.
backend/internal/api/handlers/crowdsec_handler.go In UnbanIP, after successful unban, call h.dashCache.Invalidate("dashboard") to clear cached dashboard data

New Route Registrations (in RegisterRoutes):

// Dashboard analytics endpoints (Issue #26)
rg.GET("/admin/crowdsec/dashboard/summary", h.DashboardSummary)
rg.GET("/admin/crowdsec/dashboard/timeline", h.DashboardTimeline)
rg.GET("/admin/crowdsec/dashboard/top-ips", h.DashboardTopIPs)
rg.GET("/admin/crowdsec/dashboard/scenarios", h.DashboardScenarios)
rg.GET("/admin/crowdsec/alerts", h.ListAlerts)
rg.GET("/admin/crowdsec/decisions/export", h.ExportDecisions)

Key Functions:

func (h *CrowdsecHandler) DashboardSummary(c *gin.Context)
func (h *CrowdsecHandler) DashboardTimeline(c *gin.Context)
func (h *CrowdsecHandler) DashboardTopIPs(c *gin.Context)
func (h *CrowdsecHandler) DashboardScenarios(c *gin.Context)
func (h *CrowdsecHandler) ListAlerts(c *gin.Context)
func (h *CrowdsecHandler) ExportDecisions(c *gin.Context)
func parseTimeRange(rangeStr string) (time.Time, error)
func (c *dashboardCache) Get(key string) (interface{}, bool)
func (c *dashboardCache) Set(key string, data interface{}, ttl time.Duration)
func (c *dashboardCache) Invalidate(prefixes ...string)

Database Indexes: GORM will create single-column indexes via struct tags and composite indexes via compositeIndex tags (see Section 3.3). Verify after migration:

  • Single-column: idx_security_decisions_scenario, idx_security_decisions_country, idx_security_decisions_expires_at
  • Composite: idx_sd_source_created, idx_sd_source_scenario_created, idx_sd_source_ip_created

See Appendix C for verification queries.

Acceptance Criteria:

  • All 6 endpoints return valid JSON with correct schemas
  • parseTimeRange rejects invalid inputs (returns 400)
  • Aggregation queries use parameterized GORM (no raw SQL concatenation)
  • Cache returns stale data within TTL, fresh data after TTL expires
  • Export produces valid CSV with proper escaping and JSON
  • Alerts endpoint falls back to cscli alerts list when LAPI unreachable
  • No LAPI keys in any response body
  • Unit test coverage ≥ 85%
  • GORM security scanner passes with 0 CRITICAL/HIGH

Phase 2 / PR-2: Frontend Dashboard Page with Charts

Scope: Frontend only (depends on PR-1 being merged).

Files to create:

File Description
frontend/src/pages/CrowdSecDashboard.tsx Dashboard tab orchestrator
frontend/src/components/crowdsec/DashboardSummaryCards.tsx 4 stat cards
frontend/src/components/crowdsec/BanTimelineChart.tsx Recharts AreaChart
frontend/src/components/crowdsec/TopAttackingIPsChart.tsx Recharts BarChart
frontend/src/components/crowdsec/ScenarioBreakdownChart.tsx Recharts PieChart
frontend/src/components/crowdsec/ActiveDecisionsTable.tsx Enhanced decisions table
frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx Time range toggle
frontend/src/api/crowdsecDashboard.ts API client functions
frontend/src/hooks/useCrowdsecDashboard.ts React Query hooks
frontend/src/components/crowdsec/__tests__/DashboardSummaryCards.test.tsx Unit tests
frontend/src/components/crowdsec/__tests__/BanTimelineChart.test.tsx Unit tests
frontend/src/components/crowdsec/__tests__/TopAttackingIPsChart.test.tsx Unit tests
frontend/src/components/crowdsec/__tests__/ScenarioBreakdownChart.test.tsx Unit tests
frontend/src/components/crowdsec/__tests__/ActiveDecisionsTable.test.tsx Unit tests
frontend/src/hooks/__tests__/useCrowdsecDashboard.test.ts Hook tests
tests/security/crowdsec-dashboard.spec.ts Playwright E2E tests (new file, follows existing tests/security/crowdsec-*.spec.ts convention)

Files to modify:

File Change
frontend/package.json Add recharts dependency
frontend/src/pages/CrowdSecConfig.tsx Wrap content in Radix Tabs; add Dashboard tab

Acceptance Criteria:

  • Dashboard tab renders when CrowdSec page visited and tab clicked
  • All 4 summary cards display data from /dashboard/summary
  • Timeline chart renders with Recharts AreaChart
  • Top IPs chart renders as horizontal BarChart
  • Scenario breakdown renders as PieChart/DonutChart
  • Time range selector switches all charts simultaneously
  • Loading states show skeletons (consistent with existing patterns)
  • Error states show user-friendly messages
  • Vitest unit test coverage ≥ 85%
  • Playwright E2E tests pass on Firefox, Chromium, WebKit
  • All interactive elements keyboard navigable
  • Chart colors meet WCAG 2.2 AA contrast requirements
  • Tab labels use i18n: t('crowdsec.tabs.config') and t('crowdsec.tabs.dashboard') with keys added to all locale files
  • CrowdSecDashboard is loaded via React.lazy() with <Suspense> fallback to avoid loading Recharts bundle on the Configuration tab

Phase 3 / PR-3: Alerts Feed, Enhanced Notifications, Ban Export

Scope: Frontend alerts UI + notification enrichment + export functionality.

Files to create:

File Description
frontend/src/components/crowdsec/AlertsList.tsx Alerts feed with filters
frontend/src/components/crowdsec/DecisionsExportButton.tsx Export dropdown (CSV/JSON) — moved from PR-2 for coherent slicing (button + tests in same PR)
frontend/src/components/crowdsec/__tests__/AlertsList.test.tsx Unit tests
frontend/src/components/crowdsec/__tests__/DecisionsExportButton.test.tsx Unit tests

Files to modify:

File Change
frontend/src/pages/CrowdSecDashboard.tsx Add AlertsList component
backend/internal/services/enhanced_security_notification_service.go Enrich crowdsec_decision payload with scenario and structured data

Notification Enrichment: Currently the notification service dispatches a generic event for crowdsec_decision. This PR enriches the payload to include scenario, IP, action, and duration in a structured format.

Acceptance Criteria:

  • Alerts list displays LAPI alerts with scenario, IP, events count, timestamps
  • Alerts list supports filtering by scenario
  • Export button downloads valid CSV or JSON file
  • Export filename includes timestamp
  • Notification payload includes scenario name and structured data
  • E2E tests cover export flow and alerts display
  • Unit test coverage ≥ 85%

7. Testing Strategy

7.1 Backend Unit Tests

File: backend/internal/api/handlers/crowdsec_dashboard_test.go

Test What It Validates
TestDashboardSummary_EmptyDB Returns zero counts when no decisions exist
TestDashboardSummary_WithData Returns correct aggregates for seeded data
TestDashboardSummary_InvalidRange Returns 400 for unsupported range values
TestDashboardSummary_CacheHit Second call returns cached data without DB query
TestDashboardTimeline_Buckets Correct time bucketing for 24h/1h interval
TestDashboardTimeline_EmptyRange Returns empty buckets array
TestDashboardTopIPs_Ranking IPs ordered by count descending
TestDashboardTopIPs_LimitCap Limit parameter capped at 50
TestDashboardScenarios_Breakdown Correct percentage calculation
TestListAlerts_LAPISuccess Parses LAPI response correctly
TestListAlerts_LAPIFallback Falls back to cscli on LAPI failure
TestExportDecisions_CSV Valid CSV output with headers
TestExportDecisions_JSON Valid JSON array output
TestExportDecisions_InvalidFormat Returns 400 for unsupported format
TestParseTimeRange_Valid Accepts 1h, 6h, 24h, 7d, 30d
TestParseTimeRange_Invalid Rejects arbitrary strings, negative values
TestDashboardCache_TTL Entries expire after configured TTL
TestDashboardCache_Invalidation Cache cleared on BanIP/UnbanIP

Test Fixtures: Use GORM's sqlite://file::memory:?cache=shared in-memory DB with seeded SecurityDecision records spanning multiple scenarios, IPs, and time ranges.

7.2 Frontend Unit Tests (Vitest)

Test File Key Tests
DashboardSummaryCards.test.tsx Renders 4 cards, shows loading skeleton, handles error
BanTimelineChart.test.tsx Renders Recharts AreaChart, handles empty data
TopAttackingIPsChart.test.tsx Renders BarChart, shows correct IP labels
ScenarioBreakdownChart.test.tsx Renders PieChart, shows percentages
ActiveDecisionsTable.test.tsx Renders table rows, sorts by column
AlertsList.test.tsx Renders alert items, filters by scenario
DecisionsExportButton.test.tsx Triggers correct API call for CSV/JSON
useCrowdsecDashboard.test.ts Hooks return data, handle loading/error states

Mocking: Use vi.mock('../api/crowdsecDashboard') with typed mock data matching the API schemas defined in Section 4.

7.3 E2E Tests (Playwright)

File: tests/security/crowdsec-dashboard.spec.ts

test.describe('CrowdSec Dashboard', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/security/crowdsec')
  })

  test('Dashboard tab is visible and clickable', async ({ page }) => {
    await test.step('Navigate to dashboard tab', async () => {
      await page.getByRole('tab', { name: 'Dashboard' }).click()
    })
    await test.step('Verify summary cards are visible', async () => {
      await expect(page.getByTestId('dashboard-summary-cards')).toBeVisible()
    })
  })

  test('Time range selector updates all charts', async ({ page }) => {
    // Click Dashboard tab, select 7d range, verify charts re-render
  })

  test('Export button downloads CSV file', async ({ page }) => {
    // Click Dashboard tab, click Export, select CSV, verify download
  })

  test('Active decisions table displays data', async ({ page }) => {
    // Verify table headers and at least one row (requires seed data or ban)
  })
})

Test Data: E2E tests run against the Docker container which has CrowdSec running. Tests that require specific decision data should use the existing POST /admin/crowdsec/ban endpoint to create test bans. Because BanIP now calls SecurityService.LogDecision() (see B1 fix), these bans will be persisted as SecurityDecision records and will appear in dashboard aggregation queries. Seed at least 3 bans with distinct IPs and the default duration to populate summary cards and top-IPs chart.

7.4 Test Execution Order

  1. GORM Security Scanner — Before any commit (manual stage, PR-1 only)
  2. Backend unit testscd backend && go test ./internal/api/handlers/ -run TestDashboard -v -count=1
  3. Frontend unit testscd frontend && npm run test
  4. E2E Playwright — Via task Test: E2E Playwright (FireFox) - Security Suite

8. Commit Slicing Strategy

Decision: 3 PRs (Multi-PR)

Trigger reasons:

  • Cross-domain changes (backend + frontend + new dependency)
  • Risk isolation (new dependency in separate PR aids rollback)
  • Review size (estimated ~2500 lines total; 800-1000 per PR is reviewable)
  • Testing gates (backend APIs must exist before frontend can E2E test against them)

PR Slices

Slice Scope Files (est.) Dependencies Validation Gate
PR-1 Backend: model enrichment + 6 API endpoints + cache + unit tests ~800 LOC across 3 new files + 2 modified None Backend unit tests pass ≥ 85% coverage; GORM scanner clean
PR-2 Frontend: Recharts dependency + dashboard page + charts + Vitest + E2E (no export button) ~1100 LOC across 11 new files + 2 modified PR-1 merged Vitest ≥ 85% coverage; E2E pass all browsers
PR-3 Alerts feed + notification enrichment + export button + export UI ~600 LOC across 4 new files + 2 modified PR-2 merged Unit + E2E pass; export produces valid files

Rollback & Contingency

  • PR-1 rollback: Drop new columns via migration or tolerate empty columns (GORM ignores unknown columns). Remove new routes.
  • PR-2 rollback: Revert frontend changes. Remove recharts from package.json. The backend endpoints remain harmlessly unused.
  • PR-3 rollback: Revert frontend components. Notification changes are backward-compatible (extra fields in payload are ignored by existing formatters).

9. Risk Assessment

Risk Likelihood Impact Mitigation
LAPI unavailable when CrowdSec stopped High Dashboard shows incomplete data All endpoints return graceful empty states; summary uses SQLite-only data; alerts endpoint falls back to cscli
SQLite performance on large security_decisions tables Medium Slow dashboard loads Indexed columns, time-range scoping, 30s cache TTL, LIMIT clauses
Recharts bundle size increases frontend load Low Slower initial page load Tree-shaking (only import used charts), code-split dashboard tab via React.lazy
LAPI API changes between CrowdSec versions Low Parsing failures Defensive parsing with fallback to cscli; version-agnostic field access
Country lookup accuracy Medium Inaccurate country data Country field is best-effort from LAPI's source.cn field; clearly labeled as approximate
SSRF via LAPI URL Low Internal network scanning Reuse existing validateCrowdsecLAPIBaseURL with internal-only allowlist
Sensitive IP data exposure Medium Privacy concern All dashboard endpoints behind admin auth; no public access; export requires admin role

10. Out of Scope

Item Rationale
Full CrowdSec management clone (à la crowdsec_manager) Charon embeds CrowdSec as one module, not a standalone manager
Hub browser expansion Existing preset system is sufficient; hub browsing is a separate feature
Real-time SSE/WebSocket decision streaming Adds significant complexity; polling with 30s cache is adequate for v1
GeoIP map visualization Requires map rendering library + GeoIP database; future enhancement
CrowdSec Traefik/Nginx integration Charon uses Caddy only
Custom scenario creation Users manage scenarios via CrowdSec Hub/CLI, not Charon UI
Decision auto-expiry cleanup CrowdSec handles decision lifecycle; Charon only displays
i18n for chart data labels Deferred; chart data labels use English scenario names from CrowdSec. Tab labels (Configuration, Dashboard) are i18n'd in PR-2 (see acceptance criteria).
Dark mode chart theme Deferred; initial charts use Charon's existing color palette

Appendix A: Time Range Validation

func parseTimeRange(rangeStr string) (time.Time, error) {
    now := time.Now().UTC()
    switch rangeStr {
    case "1h":
        return now.Add(-1 * time.Hour), nil
    case "6h":
        return now.Add(-6 * time.Hour), nil
    case "24h", "":
        return now.Add(-24 * time.Hour), nil
    case "7d":
        return now.Add(-7 * 24 * time.Hour), nil
    case "30d":
        return now.Add(-30 * 24 * time.Hour), nil
    default:
        return time.Time{}, fmt.Errorf("invalid range: %s (valid: 1h, 6h, 24h, 7d, 30d)", rangeStr)
    }
}

Appendix B: Dashboard Color Palette

Colors chosen for WCAG 2.2 AA compliance against both light and dark backgrounds:

Use Color Hex Contrast vs White Contrast vs #1a1a2e
Bans (primary) Blue #3b82f6 4.5:1 5.2:1
Captchas Amber #f59e0b 3.1:1 (large text) 7.8:1
Top scenario 1 Indigo #6366f1 4.6:1 4.8:1
Top scenario 2 Emerald #10b981 4.5:1 5.9:1
Top scenario 3 Rose #f43f5e 4.5:1 5.7:1
Top scenario 4 Cyan #06b6d4 4.5:1 6.1:1
Top scenario 5+ Slate #64748b 4.6:1 4.5:1

Appendix C: SQLite Index Verification

After migration, verify indexes exist:

SELECT name, tbl_name FROM sqlite_master
WHERE type = 'index' AND tbl_name = 'security_decisions';

Expected new single-column indexes: idx_security_decisions_scenario, idx_security_decisions_country, idx_security_decisions_expires_at — in addition to existing indexes on source, action, ip, host, rule_id, created_at.

Expected new composite indexes (see Section 3.3 for rationale):

Index Name Columns Purpose
idx_sd_source_created (source, created_at DESC) All time-range filtered aggregation queries
idx_sd_source_scenario_created (source, scenario, created_at DESC) Scenario breakdown, top-scenario ranking
idx_sd_source_ip_created (source, ip, created_at DESC) Top-IPs ranking, unique IP counts

Verification query for composite indexes:

SELECT name, sql FROM sqlite_master
WHERE type = 'index' AND tbl_name = 'security_decisions'
  AND name LIKE 'idx_sd_%';

strftime interval reference (used in timeline bucketing, Section 4.2):

Interval SQLite Expression
5m strftime('%Y-%m-%dT%H:', created_at) || printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5)
15m strftime('%Y-%m-%dT%H:', created_at) || printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 15) * 15)
1h strftime('%Y-%m-%dT%H:00:00Z', created_at)
6h strftime('%Y-%m-%dT', created_at) || printf('%02d:00:00Z', (CAST(strftime('%H', created_at) AS INTEGER) / 6) * 6)
1d strftime('%Y-%m-%dT00:00:00Z', created_at)

Revision History

Version Date Author Changes
1.0 2026-03-25 Initial draft
1.1 2026-03-25 Supervisor review remediation: 7 blocking fixes (B1B7) + 5 important items (I1I5). B1: Added LogDecision() call in BanIP for manual ban persistence. B2: Added composite indexes for aggregation query performance. B3: Removed top_scenario from per-IP response (simplicity). B4: Added strftime format strings for all 5 timeline intervals. B5: Added CSV formula injection sanitization requirement. B6: Specified dashCache field wiring on CrowdsecHandler struct. B7: Hybrid approach — active_decisions from LAPI, historical metrics from SQLite. I1: Tab labels use i18n keys. I2: Defined decisions_trend formula. I3: Clarified country field population from LAPI source.cn. I4: React.lazy() for dashboard tab. I5: Confirmed tests/security/ convention. Moved DecisionsExportButton.tsx to PR-3.