`, `| `, ` | ` 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`):**
```go
// 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:**
```go
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 `` 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`
```typescript
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 tests** — `cd backend && go test ./internal/api/handlers/ -run TestDashboard -v -count=1`
3. **Frontend unit tests** — `cd 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
```go
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:
```sql
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:**
```sql
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 (B1–B7) + 5 important items (I1–I5). **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. |
|