Files
BanGUI/Docs/Tasks.md
Lukas 029c094e18 Add missing jails router tests to achieve 100% line coverage
All error-handling branches in app/routers/jails.py were previously
untested: every Fail2BanConnectionError (502) path, several
JailNotFoundError (404) and JailOperationError (409) paths, and the
toggle_ignore_self endpoint which had zero coverage.

Added 26 new test cases across three new test classes
(TestIgnoreIpEndpoints extended, TestToggleIgnoreSelf,
TestFail2BanConnectionErrors) covering every remaining branch.

- app/routers/jails.py: 61% → 100% line coverage
- Overall backend coverage: 83% → 85%
- Total test count: 497 → 523 (all pass)
- ruff check and mypy --strict clean
2026-03-11 19:27:43 +01:00

727 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BanGUI — Task List
This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.
---
## Stage 1 — Dashboard Charts Foundation
### Task 1.1 — Install and configure a charting library
**Status:** `done`
The frontend currently has no charting library. Install **Recharts** (`recharts`) as the project charting library. Recharts is React-native, composable, and integrates cleanly with Fluent UI v9 theming.
**Steps:**
1. Run `npm install recharts` in the `frontend/` directory.
2. Verify the dependency appears in `package.json` under `dependencies`.
3. Confirm the build still succeeds with `npm run build` (no type errors, no warnings).
No wrapper or configuration file is needed — Recharts components are imported directly where used.
**Acceptance criteria:**
- `recharts` is listed in `frontend/package.json`.
- `npm run build` succeeds with zero errors or warnings.
---
### Task 1.2 — Create a shared chart theme utility
**Status:** `done`
Create a small utility at `frontend/src/utils/chartTheme.ts` that exports a function (or constant object) mapping Fluent UI v9 design tokens to Recharts-compatible colour values. The charts must respect the current Fluent theme (light and dark mode). At minimum export:
- A palette of 5+ distinct categorical colours for pie/bar slices, derived from Fluent token aliases (e.g. `colorPaletteBlueBorderActive`, `colorPaletteRedBorderActive`, etc.).
- Axis/grid/tooltip colours derived from `colorNeutralForeground2`, `colorNeutralStroke2`, `colorNeutralBackground1`, etc.
- A helper that returns the CSS value of a Fluent token at runtime (since Recharts needs literal CSS colour strings, not CSS custom properties).
Keep the file under 60 lines. No React components here — pure utility.
**References:** [Web-Design.md](Web-Design.md) § colour tokens.
**Acceptance criteria:**
- The exported palette contains at least 5 distinct colours.
- Colours change correctly between light and dark mode.
- `tsc --noEmit` and `eslint` pass with zero warnings.
---
## Stage 2 — Country Pie Chart (Top 4 + Other)
### Task 2.1 — Create the `TopCountriesPieChart` component
**Status:** `done`
Create `frontend/src/components/TopCountriesPieChart.tsx`. This component renders a **pie chart (Kuchendiagramm)** showing the **top 4 countries by ban count** plus an **"Other"** slice that aggregates every remaining country.
**Data source:** The component receives the `countries` map (`Record<string, number>`) and `country_names` map (`Record<string, string>`) from the existing `/api/dashboard/bans/by-country` endpoint response (`BansByCountryResponse`). No new API endpoint is needed.
**Aggregation logic (frontend):**
1. Sort the `countries` entries descending by ban count.
2. Take the top 4 entries.
3. Sum all remaining entries into a single `"Other"` bucket.
4. The result is exactly 5 slices (or fewer if fewer than 5 countries exist).
**Visual requirements:**
- Use `<PieChart>` and `<Pie>` from Recharts with `<Cell>` for per-slice colours from the chart theme palette (Task 1.2).
- Display a `<Tooltip>` on hover showing the country name and ban count.
- Display a `<Legend>` listing each slice with its country name (full name from `country_names`, not just the code) and percentage.
- Label each slice with the percentage (use Recharts `label` prop or `<Label>`).
- Use `makeStyles` for any layout styling. Follow [Web-Design.md](Web-Design.md) spacing and card conventions.
- Wrap the chart in a responsive container so it scales with its parent.
**Props interface:**
```ts
interface TopCountriesPieChartProps {
countries: Record<string, number>;
countryNames: Record<string, string>;
}
```
**Acceptance criteria:**
- Always renders exactly 5 slices (or fewer when data has < 5 countries).
- The "Other" slice correctly sums all countries outside the top 4.
- Tooltip displays country name + count on hover.
- Legend shows country name + percentage.
- Responsive — no horizontal overflow on narrow viewports.
- `tsc --noEmit` passes. No `any` types. ESLint clean.
---
### Task 2.2 — Create a `useDashboardCountryData` hook
**Status:** `done`
Create `frontend/src/hooks/useDashboardCountryData.ts`. This hook wraps the existing `GET /api/dashboard/bans/by-country` call and returns the data the dashboard charts need. The existing `useMapData` hook is designed for the map page and should not be reused because it is coupled to map-specific debouncing and state.
**Signature:**
```ts
function useDashboardCountryData(
timeRange: TimeRange,
origin: BanOriginFilter,
): {
countries: Record<string, number>;
countryNames: Record<string, string>;
bans: DashboardBanItem[];
total: number;
isLoading: boolean;
error: string | null;
};
```
**Behaviour:**
- Call `GET /api/dashboard/bans/by-country?range={timeRange}` with optional `origin` query param (omit when `"all"`).
- Use the typed API client from `api/client.ts`.
- Set `isLoading` while fetching, populate `error` on failure.
- Re-fetch when `timeRange` or `origin` changes.
- Mirror the data-fetching patterns used by `useBans` / `useMapData`.
**Acceptance criteria:**
- Returns typed data matching `BansByCountryResponse`.
- Re-fetches on param change.
- `tsc --noEmit` and ESLint pass.
---
### Task 2.3 — Integrate the pie chart into `DashboardPage`
**Status:** `done`
Add the `TopCountriesPieChart` below the `ServerStatusBar` and above the "Ban List" section on the `DashboardPage`. The chart must share the same `timeRange` and `originFilter` state that already exists on the page.
**Layout:**
- Place the pie chart inside a new section card (reuse the `section` / `sectionHeader` pattern from the existing ban-list section).
- Section title: **"Top Countries"**.
- The pie chart card sits in a future row of chart cards (see Task 3.3). For now, render it full-width. Use a CSS class name like `chartsRow` so the bar chart can be added beside it later.
**Acceptance criteria:**
- The pie chart renders on the dashboard, respecting the selected time range and origin filter.
- Changing the time range or origin filter re-renders the chart with new data.
- The loading and error states from the hook are handled (show `<Spinner>` while loading, `<MessageBar>` on error).
- `tsc --noEmit` and ESLint pass.
---
## Stage 3 — Country Bar Chart (Top 20)
### Task 3.1 — Create the `TopCountriesBarChart` component
**Status:** `done`
Create `frontend/src/components/TopCountriesBarChart.tsx`. This component renders a **horizontal bar chart (Balkendiagramm)** showing the **top 20 countries by ban count**.
**Data source:** Same `countries` and `country_names` maps from `BansByCountryResponse` — passed as props identical to the pie chart.
**Aggregation logic (frontend):**
1. Sort the `countries` entries descending by ban count.
2. Take the top 20 entries.
3. No "Other" bucket — the bar chart is detail-focused.
**Visual requirements:**
- Use `<BarChart>` (horizontal via `layout="vertical"`) from Recharts with `<Bar>`, `<XAxis>`, `<YAxis>`, `<CartesianGrid>`, and `<Tooltip>`.
- Y-axis shows country names (full name from `country_names`, truncated to ~20 chars with ellipsis if needed).
- X-axis shows ban count (numeric).
- Bars are coloured with the primary colour from the chart theme palette.
- Tooltip shows the full country name and exact ban count.
- Chart height should be dynamic based on the number of bars (e.g. `barCount * 36px` min), with a reasonable minimum height.
- Wrap in a `<ResponsiveContainer>` for width.
**Props interface:**
```ts
interface TopCountriesBarChartProps {
countries: Record<string, number>;
countryNames: Record<string, string>;
}
```
**Acceptance criteria:**
- Renders up to 20 bars, sorted descending.
- Country names readable on the Y-axis; tooltip provides full detail.
- Responsive width, dynamic height.
- `tsc --noEmit` passes. No `any`. ESLint clean.
---
### Task 3.2 — Integrate the bar chart into `DashboardPage`
**Status:** `done`
Add the `TopCountriesBarChart` to the dashboard alongside the pie chart.
**Layout:**
- The charts section now contains two cards side-by-side in a responsive grid row (the `chartsRow` class from Task 2.3):
- Left: **Top Countries** pie chart (Task 2.1).
- Right: **Top 20 Countries** bar chart (Task 3.1).
- On narrow screens (< 768 px viewport width) the cards should stack vertically.
- Both charts consume data from the **same** `useDashboardCountryData` hook call — do not fetch twice.
**Acceptance criteria:**
- Both charts render side by side on wide screens, stacked on narrow screens.
- A single API call feeds both charts.
- Time range / origin filter controls affect both charts.
- Loading / error states handled for both.
- `tsc --noEmit` and ESLint pass.
---
## Stage 4 — Bans-Over-Time Trend Chart
### Task 4.1 — Add a backend endpoint for time-series ban aggregation
**Status:** `done`
Added `GET /api/dashboard/bans/trend`. New Pydantic models `BanTrendBucket` and
`BanTrendResponse` (plus `BUCKET_SECONDS`, `BUCKET_SIZE_LABEL`, `bucket_count`
helpers) in `ban.py`. Service function `ban_trend()` in `ban_service.py` groups
`bans.timeofban` into equal-width buckets via SQL and fills empty buckets with
zero so the frontend always receives a gap-free series. Route added to
`dashboard.py`. 20 new tests (10 service, 10 router) — all pass, total suite
480 passed, 83% coverage.
The existing endpoints return flat lists or country-aggregated counts but **no time-bucketed series**. A dashboard trend chart needs data grouped into time buckets.
Create a new endpoint: **`GET /api/dashboard/bans/trend`**.
**Query params:**
| Param | Type | Default | Description |
|---|---|---|---|
| `range` | `TimeRange` | `"24h"` | Time-range preset. |
| `origin` | `BanOrigin \| null` | `null` | Optional filter by ban origin. |
**Response model** (`BanTrendResponse`):
```python
class BanTrendBucket(BaseModel):
timestamp: str # ISO 8601 UTC start of the bucket
count: int # Number of bans in this bucket
class BanTrendResponse(BaseModel):
buckets: list[BanTrendBucket]
bucket_size: str # Human-readable label: "1h", "6h", "1d", "7d"
```
**Bucket strategy:**
| Range | Bucket size | Example buckets |
|---|---|---|
| `24h` | 1 hour | 24 buckets |
| `7d` | 6 hours | 28 buckets |
| `30d` | 1 day | 30 buckets |
| `365d` | 7 days | ~52 buckets |
**Implementation:**
- Add the Pydantic models to `backend/app/models/ban.py`.
- Add the service function in `backend/app/services/ban_service.py`. Query the fail2ban database (`bans` table), group rows by the computed bucket. Use SQL `CAST((banned_at - ?) / bucket_seconds AS INTEGER)` style bucketing.
- Add the route in `backend/app/routers/dashboard.py`.
- Follow the existing layering: router → service → repository.
- Write tests for the new endpoint in `backend/tests/test_routers/` and `backend/tests/test_services/`.
**Acceptance criteria:**
- `GET /api/dashboard/bans/trend?range=24h` returns 24 hourly buckets.
- Each bucket has a correct ISO 8601 timestamp and count.
- Origin filter is applied correctly.
- Empty buckets (zero bans) are included so the frontend has a continuous series.
- Tests pass and cover happy path + empty data + origin filter.
- `ruff check` and `mypy --strict` pass.
---
### Task 4.2 — Create the `BanTrendChart` component
**Status:** `done`
Created `frontend/src/components/BanTrendChart.tsx` — an area chart using Recharts
`AreaChart` with a gradient fill, human-readable X-axis time labels (format varies by
time range), and a custom tooltip. Added `BanTrendBucket`/`BanTrendResponse` types to
`types/ban.ts`, `dashboardBansTrend` constant to `api/endpoints.ts`, `fetchBanTrend()`
to `api/dashboard.ts`, and the `useBanTrend` hook at `hooks/useBanTrend.ts`. Component
handles loading (Spinner), error (MessageBar), and empty states inline.
`tsc --noEmit` and ESLint pass with zero warnings.
Create `frontend/src/components/BanTrendChart.tsx`. This component renders an **area/line chart** showing the number of bans over time.
**Data source:** A new `useBanTrend` hook that calls `GET /api/dashboard/bans/trend`.
**Visual requirements:**
- Use `<AreaChart>` (or `<LineChart>`) from Recharts with `<Area>`, `<XAxis>`, `<YAxis>`, `<CartesianGrid>`, `<Tooltip>`.
- X-axis: time labels formatted human-readably (e.g. "Mon 14:00", "Mar 5").
- Y-axis: ban count.
- Area fill with a semi-transparent version of the primary chart colour.
- Tooltip shows exact timestamp + count.
- Responsive via `<ResponsiveContainer>`.
**Acceptance criteria:**
- Displays a continuous time-series line with the correct number of data points for each range.
- Readable axis labels for all four time ranges.
- Responsive.
- `tsc --noEmit`, ESLint clean.
---
### Task 4.3 — Integrate the trend chart into `DashboardPage`
**Status:** `done`
Added a "Ban Trend" full-width section card to `DashboardPage` between the
`ServerStatusBar` and the "Top Countries" section. The section renders
`<BanTrendChart timeRange={timeRange} origin={originFilter} />`, sharing the
same state already used by the country charts and ban list. Loading, error,
and empty states are handled inside `BanTrendChart` itself. `tsc --noEmit` and
ESLint pass with zero warnings.
Add the `BanTrendChart` to the dashboard page **above** the two country charts and **below** the `ServerStatusBar`.
**Layout:**
- Full-width section card.
- Section title: **"Ban Trend"**.
- Shares the same `timeRange` and `originFilter` state.
**Acceptance criteria:**
- The trend chart renders on the dashboard showing bans over time.
- Responds to time-range and origin-filter changes.
- Loading/error states handled.
- `tsc --noEmit` and ESLint pass.
---
## Stage 5 — Jail Distribution Chart
### Task 5.1 — Add a backend endpoint for ban counts per jail
**Status:** `done`
Added `GET /api/dashboard/bans/by-jail`. New Pydantic models `JailBanCount` and
`BansByJailResponse` added to `ban.py`. Service function `bans_by_jail()` in
`ban_service.py` queries the `bans` table with `GROUP BY jail ORDER BY COUNT(*) DESC`
and applies the origin filter. Route added to `dashboard.py`. 7 new service tests
(happy path, total equality, empty DB, time-window exclusion, origin filter variants)
and 10 new router tests — all pass, total suite 497 passed, 83% coverage.
`ruff check` and `mypy --strict` pass.
The existing `GET /api/jails` endpoint returns jail metadata with `status.currently_banned` — but this counts **currently active** bans, not historical bans in the selected time window. The dashboard needs historical ban counts per jail within the selected time range.
Create a new endpoint: **`GET /api/dashboard/bans/by-jail`**.
**Query params:**
| Param | Type | Default | Description |
|---|---|---|---|
| `range` | `TimeRange` | `"24h"` | Time-range preset. |
| `origin` | `BanOrigin \| null` | `null` | Optional origin filter. |
**Response model** (`BansByJailResponse`):
```python
class JailBanCount(BaseModel):
jail: str
count: int
class BansByJailResponse(BaseModel):
jails: list[JailBanCount]
total: int
```
**Implementation:**
- Query the `bans` table: `SELECT jail, COUNT(*) FROM bans WHERE timestart >= ? GROUP BY jail ORDER BY COUNT(*) DESC`.
- Apply origin filter by checking whether `jail == 'blocklist-import'`.
- Add models, service function, route, and tests following existing patterns.
**Acceptance criteria:**
- Returns jail names with ban counts descending, within the selected time window.
- Origin filter works correctly.
- Tests covering happy path, empty data, and filter.
- `ruff check` and `mypy --strict` pass.
---
### Task 5.2 — Create the `JailDistributionChart` component
**Status:** `done`
Created `frontend/src/components/JailDistributionChart.tsx` — a horizontal
bar chart using Recharts `BarChart` showing ban counts per jail sorted descending.
Added `JailBanCount`/`BansByJailResponse` types to `types/ban.ts`,
`dashboardBansByJail` constant to `api/endpoints.ts`, `fetchBansByJail()` to
`api/dashboard.ts`, and the `useJailDistribution` hook at
`hooks/useJailDistribution.ts`. Component handles loading (Spinner), error
(MessageBar), and empty states inline. `tsc --noEmit` and ESLint pass with zero
warnings.
Create `frontend/src/components/JailDistributionChart.tsx`. This component renders a **horizontal bar chart** showing the distribution of bans across jails.
**Why this is useful and not covered by existing views:** The current Jails page shows configuration details and live counters per jail, but **does not** provide a visual comparison of which jails are catching the most threats within a selectable time window. An admin reviewing the dashboard benefits from an at-a-glance answer to: *"Which services are being attacked most frequently right now?"* — this is fundamentally different from the country-based charts (which answer *"where"*) and from the ban trend (which answers *"when"*). The jail distribution answers **"what service is targeted"** and helps prioritise hardening efforts.
**Data source:** A new `useJailDistribution` hook calling `GET /api/dashboard/bans/by-jail`.
**Visual requirements:**
- Horizontal `<BarChart>` from Recharts.
- Y-axis: jail names.
- X-axis: ban count.
- Colour-coded bars from the chart theme.
- Tooltip with jail name and exact count.
- Responsive.
**Acceptance criteria:**
- Renders one bar per jail, sorted descending.
- Responsive.
- `tsc --noEmit`, ESLint clean.
---
### Task 5.3 — Integrate the jail distribution chart into `DashboardPage`
**Status:** `done`
Added a full-width "Jail Distribution" section card to `DashboardPage` below the
"Top Countries" section (2-column country charts on row 1, jail chart full-width
on row 2). The section renders `<JailDistributionChart timeRange={timeRange}
origin={originFilter} />`, sharing the same state already used by the other
charts. Loading, error, and empty states are handled inside
`JailDistributionChart` itself. `tsc --noEmit` and ESLint pass with zero warnings.
Add the `JailDistributionChart` as a third chart card alongside the two country charts, or in a second chart row below them if space is constrained.
**Layout decision:**
- If three cards fit side-by-side at the standard breakpoint, place all three in one row.
- Otherwise, use a 2-column + 1-column stacked layout (pie + bar on row 1, jail chart full-width on row 2). Choose whichever looks cleaner.
**Acceptance criteria:**
- The jail distribution chart renders on the dashboard.
- Shares time-range and origin-filter controls with the other charts.
- Loading/error states handled.
- Responsive layout.
- `tsc --noEmit` and ESLint pass.
---
## Stage 6 — Polish and Final Review
### Task 6.1 — Ensure consistent loading, error, and empty states across all charts
**Status:** `done`
Review all four chart components and ensure:
1. **Loading state**: Each shows a Fluent UI `<Spinner>` centred in its card while data is fetching.
2. **Error state**: Each shows a Fluent UI `<MessageBar intent="error">` with a retry button.
3. **Empty state**: When the data set has zero bans, each chart shows a friendly message (e.g. "No bans in this time range") instead of an empty or broken chart.
Extract a small shared wrapper if three or more charts duplicate the same loading/error/empty pattern (e.g. `ChartCard` or `ChartStateWrapper`).
**Acceptance criteria:**
- All charts handle loading, error, and empty states consistently.
- No broken or blank chart renders when data is unavailable.
- `tsc --noEmit` and ESLint pass.
---
### Task 6.2 — Write frontend tests for chart components
**Status:** `done`
Add tests for each chart component to confirm:
- Correct number of rendered slices/bars given known test data.
- "Other" aggregation logic in the pie chart.
- Top-N truncation in the bar chart.
- Hook re-fetch on prop change.
- Loading and error states render the expected UI.
Follow the project's existing frontend test setup and conventions.
**Acceptance criteria:**
- Each chart component has at least one happy-path and one edge-case test.
- Tests pass.
- ESLint clean.
---
### Task 6.3 — Full build and lint check
**Status:** `done`
Run the complete quality-assurance pipeline:
1. Backend: `ruff check`, `mypy --strict`, `pytest` with coverage.
2. Frontend: `tsc --noEmit`, `eslint`, `npm run build`.
3. Fix any warnings or errors introduced during stages 16.
4. Verify overall test coverage remains ≥ 80 %.
**Acceptance criteria:**
- Zero lint warnings/errors on both backend and frontend.
- All tests pass.
- Build artifacts generated successfully.
---
## Stage 7 — Global Dashboard Filter Bar
The time-range and origin-filter controls currently live inside the "Ban List" section header, but they control **every** section on the dashboard (Ban Trend, Top Countries, Jail Distribution, **and** Ban List). This creates a misleading UX: the buttons appear scoped to the ban list when they are actually global. This stage extracts those controls into a dedicated, always-visible filter bar at the top of the dashboard, directly below the `ServerStatusBar`.
### Task 7.1 — Create the `DashboardFilterBar` component
**Status:** `done`
Create `frontend/src/components/DashboardFilterBar.tsx`. This is a self-contained toolbar component that renders the time-range presets and origin filter as two groups of toggle buttons.
**Props interface:**
```ts
interface DashboardFilterBarProps {
timeRange: TimeRange;
onTimeRangeChange: (value: TimeRange) => void;
originFilter: BanOriginFilter;
onOriginFilterChange: (value: BanOriginFilter) => void;
}
```
**Visual requirements:**
- Render inside a card-like container using the existing `section` style pattern (neutral background, border, border-radius, padding) — but **without** a section title. The toolbar **is** the content.
- Layout: a single row with two `<Toolbar>` groups separated by a visual divider (use Fluent UI `<Divider vertical>` or a horizontal gap of `spacingHorizontalXL`).
- **Left group** — "Time Range" label + four `<ToggleButton>` presets:
- `Last 24 h` (value `"24h"`)
- `Last 7 days` (value `"7d"`)
- `Last 30 days` (value `"30d"`)
- `Last 365 days` (value `"365d"`)
- **Right group** — "Filter" label + three `<ToggleButton>` options:
- `All` (value `"all"`)
- `Blocklist` (value `"blocklist"`)
- `Selfblock` (value `"selfblock"`)
- Each group label is a `<Text weight="semibold" size={300}>` rendered inline before the buttons.
- Use `size="small"` on all toggle buttons. The active button uses `checked={true}` and `aria-pressed={true}`.
- On narrow viewports (< 640 px), the two groups should **wrap** onto separate lines (use `flexWrap: "wrap"` on the outer container).
- Reuse `TIME_RANGE_LABELS` and `BAN_ORIGIN_FILTER_LABELS` from `types/ban.ts` — no hard-coded label strings.
- Use `makeStyles` for all styling. Follow [Web-Design.md](Web-Design.md) spacing conventions: `spacingHorizontalM` between buttons within a group, `spacingHorizontalXL` between groups, `spacingVerticalS` for vertical padding.
**Behaviour:**
- Clicking a time-range button calls `onTimeRangeChange(value)`.
- Clicking an origin-filter button calls `onOriginFilterChange(value)`.
- Exactly one button per group is active at any time (mutually exclusive — not multi-select).
- Component is fully controlled: it does not own state, it receives and reports values only.
**File structure rules:**
- One component per file. No barrel exports needed — import directly.
- Keep under 100 lines.
**Acceptance criteria:**
- The component renders two labelled button groups in a single row.
- Calls the correct callback with the correct value when a button is clicked.
- Buttons reflect the current selection via `checked` / `aria-pressed`.
- Wraps gracefully on narrow viewports.
- `tsc --noEmit` passes. No `any`. ESLint clean.
---
### Task 7.2 — Integrate `DashboardFilterBar` into `DashboardPage`
**Status:** `done`
Move the global filter controls out of the "Ban List" section and replace them with the new `DashboardFilterBar`, placed at the top of the page.
**Changes to `DashboardPage.tsx`:**
1. **Add** `<DashboardFilterBar>` immediately **below** `<ServerStatusBar />` and **above** the "Ban Trend" section. Pass the existing `timeRange`, `setTimeRange`, `originFilter`, and `setOriginFilter` as props.
2. **Remove** the two `<Toolbar>` blocks (time-range selector and origin filter) that are currently inside the "Ban List" section header. The section header should keep only the `<Text as="h2">Ban List</Text>` title — no filter buttons.
3. **Remove** the `TIME_RANGES` and `ORIGIN_FILTERS` local constant arrays from `DashboardPage.tsx` since the `DashboardFilterBar` component now owns the iteration. (If `DashboardFilterBar` re-uses these arrays, it defines them locally or imports them from `types/ban.ts`.)
4. **Keep** the `timeRange` and `originFilter` state (`useState`) in `DashboardPage` — the page still owns the state; it just no longer renders the buttons directly.
5. **Verify** that all sections (Ban Trend, Top Countries, Jail Distribution, Ban List) still receive the filter values as props and re-render when they change — this should already work since the state location is unchanged.
**Layout after change:**
```
┌──────────────────────────────────────┐
│ ServerStatusBar │
├──────────────────────────────────────┤
│ DashboardFilterBar │ ← NEW location
│ [24h] [7d] [30d] [365d] │ [All] [Blocklist] [Selfblock] │
├──────────────────────────────────────┤
│ Ban Trend (chart) │
├──────────────────────────────────────┤
│ Top Countries (pie + bar) │
├──────────────────────────────────────┤
│ Jail Distribution (bar) │
├──────────────────────────────────────┤
│ Ban List (table) │ ← filters REMOVED from here
└──────────────────────────────────────┘
```
**Acceptance criteria:**
- The filter bar is visible at the top of the dashboard, below the status bar.
- Changing a filter updates all four sections simultaneously.
- The "Ban List" section header no longer contains filter buttons.
- No functional regression — the dashboard behaves identically, filters are just relocated.
- `tsc --noEmit` and ESLint pass.
---
### Task 7.3 — Write tests for `DashboardFilterBar`
**Status:** `done`
Create `frontend/src/components/__tests__/DashboardFilterBar.test.tsx`.
**Test cases:**
1. **Renders all time-range buttons** — confirm four buttons with correct labels appear.
2. **Renders all origin-filter buttons** — confirm three buttons with correct labels appear.
3. **Active state matches props** — given `timeRange="7d"` and `originFilter="blocklist"`, the corresponding buttons have `aria-pressed="true"` and the others `"false"`.
4. **Time-range click fires callback** — click the "Last 30 days" button, assert `onTimeRangeChange` was called with `"30d"`.
5. **Origin-filter click fires callback** — click the "Selfblock" button, assert `onOriginFilterChange` was called with `"selfblock"`.
6. **Already-active button click still fires callback** — clicking the currently active button should still call the callback (no no-op guard).
**Test setup:**
- Wrap the component in `<FluentProvider theme={webLightTheme}>` (required for Fluent UI token resolution).
- Use `vi.fn()` for the callback props.
- Follow the existing test patterns in `frontend/src/components/__tests__/`.
**Acceptance criteria:**
- All 6 test cases pass.
- Tests are fully typed — no `any`.
- ESLint clean.
---
### Task 7.4 — Final lint, type-check, and build verification
**Status:** `done`
Run the full quality-assurance pipeline after the filter-bar changes:
1. `tsc --noEmit` — zero errors.
2. `npm run lint` — zero warnings, zero errors.
3. `npm run build` — succeeds.
4. `npm test` — all frontend tests pass (including the new `DashboardFilterBar` tests).
5. Backend: `ruff check`, `mypy --strict`, `pytest` — still green (no backend changes expected, but verify no accidental modifications).
**Acceptance criteria:**
- Zero lint warnings/errors.
- All tests pass on both frontend and backend.
- Production build succeeds.
---
## Stage 8 — Jails Router Test Coverage
### Task 8.1 — Bring jails router to 100 % line coverage
**Status:** `done`
`app/routers/jails.py` currently sits at **61 %** line coverage (54 of 138 lines uncovered). The missing lines are exclusively error-handling paths — the 502 `Fail2BanConnectionError` branch across every endpoint, several 404/409 branches in the jail-control and ignore-list endpoints, and the `toggle_ignore_self` endpoint which has no tests at all. These are critical banning-related paths that the Instructions require to be fully covered.
**Missing coverage (uncovered lines):**
| Lines | Endpoint | Missing path |
|---|---|---|
| 69 | `_bad_gateway` helper | One-time body — hit by first 502 test |
| 120121 | `GET /api/jails` | `Fail2BanConnectionError` → 502 |
| 157158 | `GET /api/jails/{name}` | `Fail2BanConnectionError` → 502 |
| 195198 | `POST /api/jails/reload-all` | `JailOperationError` → 409 and `Fail2BanConnectionError` → 502 |
| 234235 | `POST /api/jails/{name}/start` | `Fail2BanConnectionError` → 502 |
| 270273 | `POST /api/jails/{name}/stop` | `JailOperationError` → 409 and `Fail2BanConnectionError` → 502 |
| 314319 | `POST /api/jails/{name}/idle` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 351356 | `POST /api/jails/{name}/reload` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 399402 | `GET /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `Fail2BanConnectionError` → 502 |
| 449454 | `POST /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 491496 | `DELETE /api/jails/{name}/ignoreip` | `JailNotFoundError` → 404, `JailOperationError` → 409, `Fail2BanConnectionError` → 502 |
| 529542 | `POST /api/jails/{name}/ignoreself` | All paths (entirely untested) |
**Implementation:**
- Add new test classes / test methods to `backend/tests/test_routers/test_jails.py`.
- Follow the naming pattern: `test_<unit>_<scenario>_<expected>`.
- Each 502 test mocks the service function to raise `Fail2BanConnectionError`.
- Each 404 test mocks the service to raise `JailNotFoundError`.
- Each 409 test mocks the service to raise `JailOperationError`.
- Wrap `toggle_ignore_self` tests in a `TestToggleIgnoreSelf` class covering: 200 (on), 200 (off), 404, 409, 502.
- No changes to production code required — this is a pure test addition.
**Acceptance criteria:**
- `app/routers/jails.py` reaches **100 %** line coverage.
- All new tests use `AsyncMock` and follow existing test patterns.
- `ruff check` and `mypy --strict` pass (tests are type-clean).
- Total test suite still passes (`497 + N` tests passing).