diff --git a/Docker/VERSION b/Docker/VERSION index 6762819..30bf7e7 100644 --- a/Docker/VERSION +++ b/Docker/VERSION @@ -1 +1 @@ -v0.9.15 +v0.9.18 diff --git a/Docker/fail2ban-dev-config/README.md b/Docker/fail2ban-dev-config/README.md index 6ecaf56..6422e00 100644 --- a/Docker/fail2ban-dev-config/README.md +++ b/Docker/fail2ban-dev-config/README.md @@ -78,6 +78,11 @@ Chains steps 1–3 automatically with appropriate sleep intervals. Inside the container the log file is mounted at `/remotelogs/bangui/auth.log` (see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`). +BanGUI also extends fail2ban history retention for archive backfill. In +the development config `fail2ban/fail2ban.conf` the database purge age is +set to `648000` seconds (7.5 days) so the first archive sync can recover a +full 7-day window before fail2ban purges old rows. + To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`: ```ini diff --git a/Docs/Features.md b/Docs/Features.md index 058fbe9..a4880d9 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -52,6 +52,8 @@ The main landing page after login. Shows recent ban activity at a glance. - Last 7 days (week) - Last 30 days (month) - Last 365 days (year) +- **Data source selection:** The "Last 24 hours" preset queries fail2ban's live database directly for real-time accuracy. All longer presets (7 days, 30 days, 365 days) query the BanGUI long-term archive, because fail2ban's own database only retains the last 24 hours by default. +- A **data-source badge** next to the time-range selector indicates whether the current view is showing **Live (fail2ban DB)** or **Archive (BanGUI DB)** data. --- @@ -70,14 +72,21 @@ A geographical overview of ban activity. - Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend) - The color threshold values are configurable through the application settings - **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner. -- For every country that has bans, the total count is displayed centred inside that country's borders in the selected time range. -- Countries with zero banned IPs show no number and no label — they remain blank and transparent. -- Clicking a country filters the companion table below to show only bans from that country. +- For every country that has bans, the total count is shown only in the country tooltip, not rendered on the map itself. +- Countries with zero banned IPs show no tooltip and remain blank and transparent. +- Clicking a country filters the companion table below to show only bans from that country. When a country is selected the server returns the **complete** list of bans for that country in the chosen time window — the default 200-row companion cap is lifted for filtered queries. Clicking the same country again or using the "Clear filter" button reverts to the standard unfiltered view. - Time-range selector with the same quick presets: - Last 24 hours - Last 7 days - Last 30 days - Last 365 days +- **Data source selection:** Same rule as the Dashboard — "Last 24 hours" uses the live fail2ban database; all other ranges use the BanGUI archive. +- A **data-source badge** is displayed alongside the time-range selector indicating **Live (fail2ban DB)** or **Archive (BanGUI DB)**. + +### Companion Table + +- The column header row is always visible at the top of the scrollable table area (sticky positioning) so column labels remain readable regardless of scroll position. +- The pagination / page-size bar is always visible at the bottom of the scrollable table area (sticky positioning) so the user can navigate pages without scrolling back down. --- @@ -245,13 +254,15 @@ A page to inspect and modify the fail2ban configuration without leaving the web ## 7. Ban History -A view for exploring historical ban data stored in the fail2ban database. +A view for exploring historical ban data stored in the BanGUI long-term archive. ### History Table - Browse all past bans across all jails, not just the currently active ones. - **Columns:** Time of ban, IP address, jail, ban duration, ban count (how many times this IP was banned), country. - Filter by jail, by IP address, or by time range. +- The default time range on first load is **Last 7 days** and the data source is always the **BanGUI archive**, ensuring the full retention window is visible regardless of fail2ban's `dbpurgeage` setting. +- A **data-source badge** is displayed indicating **Archive (BanGUI DB)**. - See at a glance which IPs are repeat offenders (high ban count). ### Per-IP History @@ -265,7 +276,7 @@ A view for exploring historical ban data stored in the fail2ban database. - On each configured sync cycle (default every 5 minutes), BanGUI reads latest entries from fail2ban `bans` table and appends any new events to BanGUI history storage. - Supports both `ban` and `unban` events; audit record includes: `timestamp`, `ip`, `jail`, `action`, `duration`, `origin` (manual, auto, blocklist, etc.), `failures`, `matches`, and optional `country` / `ASN` enrichment. - Includes incremental import logic with dedupe: using unique constraint on (ip, jail, action, timeofban) to prevent duplication across sync cycles. -- Provides backfill mode for initial startup: import last N days (configurable, default 7 days) of existing fail2ban history into BanGUI to avoid dark gaps after restart. +- Provides backfill mode for initial startup: import the last 7.5 days of existing fail2ban history into BanGUI to avoid dark gaps after restart. Requires fail2ban's `dbpurgeage` to be set to at least `648000` (7.5 days) — BanGUI ships with this value pre-configured in its Docker setup. - Includes configurable archive purge policy in BanGUI (default 365 days), separate from fail2ban `dbpurgeage`, to keep app storage bounded while preserving audit data. - Expose API endpoints for querying persistent history, with filters for timeframe, jail, origin, IP, and current ban status. - On fail2ban connectivity failure, BanGUI continues serving historical data; next successful sync resumes ingestion without data loss. diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 5e2cf75..e6a4eab 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -8,37 +8,124 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. ## Open Issues -### 1. History Screen — Move Jail & IP Address Filters Into the Time Range Bar +--- -**Goal:** Unify all History-page filters into a single bordered bar so that Jail and IP Address sit inside the same card/border as Time Range and Filter, separated by vertical dividers. +### TASK-001 — WorldMap: filter companion table by selected country (server-side) -**Current state:** -- `DashboardFilterBar` (`frontend/src/components/DashboardFilterBar.tsx`) renders a single bordered card (`cardStyles.card`) that contains two groups — **Time Range** (toggle buttons) and **Filter** (origin toggle buttons) — separated by a vertical ``. -- In `HistoryPage` (`frontend/src/pages/HistoryPage.tsx`, lines 476–510) the Jail `` and IP Address `` are rendered **outside** that bar as separate cards, each wrapped in their own `cardStyles.card` div, laid out horizontally via `styles.filterRow` (flexbox row with gap). +**Status:** Done +**Priority:** Medium +**Domain:** Full-stack (backend + frontend) +**References:** `Docs/Features.md §4`, `Docs/Web-Development.md` -**Desired state:** -- The Jail and IP Address inputs must move **inside** the `DashboardFilterBar` card border (or the equivalent combined container) so the entire filter strip looks like one cohesive section. -- Each new group (Jail, IP Address) is separated from its neighbor by a vertical divider (`|`), using the same `` + `styles.divider` pattern already used between Time Range and Filter. -- Inside each group the label text ("Jail", "IP Address") must appear **to the left** of its input field (i.e. `flexDirection: "row"` with `alignItems: "center"`, not above it). This matches the existing group style where the title text sits to the left of the toolbar buttons. -- The visual order inside the bar is: **Time Range** | **Filter** | **Jail** | **IP Address**. +#### Background -**Files to change:** +The `GET /api/dashboard/bans/by-country` endpoint always returns the **200 most recent** ban rows in `bans` (constant `_MAX_COMPANION_BANS = 200` in `backend/app/services/ban_service.py`). `MapPage.tsx` stores a `selectedCountry` state and filters the returned rows client-side via `visibleBans`. This means the companion table can only show the fraction of a country's bans that fall within the global top-200 window. If the selected time range has, say, 1 500 bans and 300 are from China, but China's bans are not all in the top 200 overall, the table will silently display fewer than 300 rows. -1. **`frontend/src/components/DashboardFilterBar.tsx`** - - Accept two new optional props (e.g. `jailSlot?: React.ReactNode` and `ipSlot?: React.ReactNode`, or pass the value+onChange pairs directly). Keep the component reusable — the Dashboard page uses the same component but does not need the Jail/IP inputs, so these slots must be optional. - - After the existing Filter group, conditionally render a `
` + `` followed by a new group for Jail, and repeat for IP Address. - - Each new group should follow the existing `styles.group` layout: a row with the label `` on the left and the `` on the right, separated by `gap: tokens.spacingHorizontalM`. +When a country is selected the companion table **must** return the complete set of bans for that country so the user sees an accurate picture. -2. **`frontend/src/pages/HistoryPage.tsx`** - - Remove the two standalone Jail and IP Address `
` cards (currently wrapped in `styles.filterLabel` + `cardStyles.card`). - - Instead, pass the Jail and IP Address controls into `` via the new props/slots. - - The `styles.filterLabel` style can be removed if no longer used elsewhere. +#### Desired behaviour + +- No country selected → companion table shows the 200 most recent bans across all countries (existing behaviour, no change). +- Country selected → the server returns **all** ban entries for that country in the selected time window; no client-side row-count cap applies. +- Deselecting a country (clicking the same country again, or the "Clear filter" button) reverts to the default 200-row unfiltered view. +- The existing `visibleBans` client-side filter in `MapPage.tsx` can remain as a defensive guard but must not be the only filter. + +#### Implementation steps + +1. **Backend — router** (`backend/app/routers/dashboard.py`) + - Add `country_code: str | None = Query(default=None, description="ISO alpha-2 country code to filter companion rows.")` to `get_bans_by_country`. + - Pass it to `ban_service.bans_by_country(..., country_code=country_code)`. + +2. **Backend — service** (`backend/app/services/ban_service.py`) + - Add `country_code: str | None = None` keyword argument to `bans_by_country`. + - After `geo_map` is built (existing geo-resolution step), collect IPs whose resolved country matches `country_code`. + - For the **fail2ban source**: call `fail2ban_db_repo.get_currently_banned` with `ip_filter=matched_ips` and no `limit` (remove the `_MAX_COMPANION_BANS` cap for filtered queries). + - For the **archive source**: filter `all_rows` to those whose IP is in `matched_ips` and return all of them (skip the `page_size=_MAX_COMPANION_BANS` call). + - When `country_code` is `None`, behaviour is identical to today. + +3. **Backend — repository** (`backend/app/repositories/fail2ban_db_repo.py`) + - Add `ip_filter: list[str] | None = None` to `get_currently_banned`. + - When provided and non-empty, append `AND ip IN ({placeholders})` to the SQL `WHERE` clause, parameterised safely (never interpolated as a string). + +4. **Backend — repository (archive)** (`backend/app/repositories/history_archive_repo.py`) + - Similarly add optional `ip_filter` to the archive companion-rows query used from `bans_by_country`. + +5. **Frontend — API client** (`frontend/src/api/map.ts`) + - Add optional `countryCode?: string` parameter to `fetchBansByCountry`. + - When set, append `country_code=` to the query string. + +6. **Frontend — hook** (`frontend/src/hooks/useMapData.ts`) + - Add `countryCode?: string` to the function signature. + - Include it in the `useCallback` dependency array and pass it to `fetchBansByCountry`. + +7. **Frontend — page** (`frontend/src/pages/MapPage.tsx`) + - Pass `selectedCountry ?? undefined` as `countryCode` to `useMapData`. + - The hook's effect will re-fetch automatically when `selectedCountry` changes; the existing `useEffect` that resets `page` to 1 already covers this. + +#### Testing guidance + +- Select a country that has > 200 bans in the chosen time window; confirm the companion table shows more than the previous cap would allow. +- With no country selected, confirm only 200 rows are returned (no regression). +- Deselect the country; confirm the unfiltered 200-row view is restored. +- Test with the archive source as well as the fail2ban live source. +- Verify the `ip_filter` SQL clause is parameterised and cannot be injected. + +--- + +### TASK-002 — WorldMap: sticky table header and sticky pagination bar + +**Status:** Done +**Priority:** Low +**Domain:** Frontend only +**References:** `Docs/Features.md §4`, `Docs/Web-Design.md`, `Docs/Web-Development.md` + +#### Background + +The companion ban table in `MapPage.tsx` is wrapped in `tableWrapper` (CSS `overflow: auto; maxHeight: 420px`). Both the Fluent UI `TableHeader` row and the `.pagination` div inside `tableWrapper` scroll with the content. Once the user scrolls more than a few rows, the column header labels disappear and the pagination controls become unreachable without scrolling back to the top or bottom. + +#### Desired behaviour + +- The column header row (`TableHeader →TableRow → TableHeaderCell × 6`) must remain fixed at the **top** of the scrollable container at all times. +- The pagination / page-size bar (`.pagination` div at the bottom of `tableWrapper`) must remain fixed at the **bottom** of the scrollable container at all times. +- Rows in `TableBody` scroll normally between the two fixed ends. +- No changes to the container height, overall layout, or other pages. + +#### Implementation steps + +All changes are in `frontend/src/pages/MapPage.tsx`. + +1. **Sticky table header cells** + - In `useStyles` (`makeStyles`), add a new class: + ```ts + stickyHeaderCell: { + position: "sticky", + top: 0, + zIndex: 1, + backgroundColor: tokens.colorNeutralBackground1, + boxShadow: `0 1px 0 ${tokens.colorNeutralStroke2}`, + }, + ``` + - Apply `className={styles.stickyHeaderCell}` to **each** `TableHeaderCell` in the header row. + - Note: `position: sticky` on `` elements is unreliable across browsers for table layouts; apply it to each `` (`TableHeaderCell`) instead. + +2. **Sticky pagination bar** + - In the existing `pagination` entry in `useStyles`, add: + ```ts + position: "sticky", + bottom: 0, + zIndex: 1, + ``` + - The existing `backgroundColor: tokens.colorNeutralBackground2` already prevents table rows from bleeding through. + +3. **No other changes** — do not alter `tableWrapper`, its height, or anything outside `MapPage.tsx`. + +#### Testing guidance + +- Load the Map page with a time range that produces > 25 bans (enough to overflow the `420px` container). +- Scroll down through the table and confirm the column headers remain visible at the top. +- Scroll down and confirm the pagination bar remains visible at the bottom. +- Verify no visual artefacts (table body rows must not overlap or bleed through the sticky elements). +- Run `tsc --noEmit` — zero type errors expected. +- Run existing frontend tests: `vitest run` — no regressions. -**Acceptance criteria:** -- All four filter groups (Time Range, Filter, Jail, IP Address) render inside a single bordered bar. -- Each group is separated by a vertical divider identical to the existing one between Time Range and Filter. -- The labels "Jail" and "IP Address" sit to the **left** of their respective input fields (horizontal layout), not above them. -- The Dashboard page's usage of `DashboardFilterBar` is unaffected (no Jail/IP inputs shown there). -- Existing filter functionality (debounced input, query params, pagination reset) remains unchanged. -Status: completed diff --git a/backend/app/repositories/fail2ban_db_repo.py b/backend/app/repositories/fail2ban_db_repo.py index 06b8419..ce6c10c 100644 --- a/backend/app/repositories/fail2ban_db_repo.py +++ b/backend/app/repositories/fail2ban_db_repo.py @@ -126,6 +126,7 @@ async def get_currently_banned( since: int, origin: BanOrigin | None = None, *, + ip_filter: list[str] | None = None, limit: int | None = None, offset: int | None = None, ) -> tuple[list[BanRecord], int]: @@ -135,6 +136,7 @@ async def get_currently_banned( db_path: File path to the fail2ban SQLite database. since: Unix timestamp to filter bans newer than or equal to. origin: Optional origin filter. + ip_filter: Optional list of IP addresses to restrict the result to. limit: Optional maximum number of rows to return. offset: Optional offset for pagination. @@ -142,14 +144,21 @@ async def get_currently_banned( A ``(records, total)`` tuple. """ + if ip_filter is not None and len(ip_filter) == 0: + return [], 0 + origin_clause, origin_params = _origin_sql_filter(origin) + ip_filter_clause = "" + if ip_filter is not None: + placeholder = ", ".join("?" for _ in ip_filter) + ip_filter_clause = f" AND ip IN ({placeholder})" async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db: db.row_factory = aiosqlite.Row async with db.execute( - "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause, - (since, *origin_params), + "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause + ip_filter_clause, + (since, *origin_params, *(ip_filter or [])), ) as cur: count_row = await cur.fetchone() total: int = int(count_row[0]) if count_row else 0 @@ -157,9 +166,9 @@ async def get_currently_banned( query = ( "SELECT jail, ip, timeofban, bancount, data " "FROM bans " - "WHERE timeofban >= ?" + origin_clause + " ORDER BY timeofban DESC" + "WHERE timeofban >= ?" + origin_clause + ip_filter_clause + " ORDER BY timeofban DESC" ) - params: list[object] = [since, *origin_params] + params: list[object] = [since, *origin_params, *(ip_filter or [])] if limit is not None: query += " LIMIT ?" params.append(limit) diff --git a/backend/app/repositories/history_archive_repo.py b/backend/app/repositories/history_archive_repo.py index 8f1b599..184e7d4 100644 --- a/backend/app/repositories/history_archive_repo.py +++ b/backend/app/repositories/history_archive_repo.py @@ -40,13 +40,16 @@ async def get_archived_history( db: aiosqlite.Connection, since: int | None = None, jail: str | None = None, - ip_filter: str | None = None, + ip_filter: str | list[str] | None = None, origin: BanOrigin | None = None, action: str | None = None, page: int = 1, page_size: int = 100, ) -> tuple[list[dict], int]: """Return a paginated archived history result set.""" + if isinstance(ip_filter, list) and len(ip_filter) == 0: + return [], 0 + wheres: list[str] = [] params: list[object] = [] @@ -59,8 +62,13 @@ async def get_archived_history( params.append(jail) if ip_filter is not None: - wheres.append("ip LIKE ?") - params.append(f"{ip_filter}%") + if isinstance(ip_filter, list): + placeholder = ", ".join("?" for _ in ip_filter) + wheres.append(f"ip IN ({placeholder})") + params.extend(ip_filter) + else: + wheres.append("ip LIKE ?") + params.append(f"{ip_filter}%") if origin == "blocklist": wheres.append("jail = ?") @@ -108,7 +116,7 @@ async def get_all_archived_history( db: aiosqlite.Connection, since: int | None = None, jail: str | None = None, - ip_filter: str | None = None, + ip_filter: str | list[str] | None = None, origin: BanOrigin | None = None, action: str | None = None, ) -> list[dict]: diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 2179ac5..644fb61 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table, from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: import aiohttp @@ -83,7 +83,10 @@ async def get_dashboard_bans( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query( + default="fail2ban", + description="Data source: 'fail2ban' or 'archive'.", + ), page: int = Query(default=1, ge=1, description="1-based page number."), page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."), origin: BanOrigin | None = Query( @@ -137,11 +140,18 @@ async def get_bans_by_country( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query( + default="fail2ban", + description="Data source: 'fail2ban' or 'archive'.", + ), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", ), + country_code: str | None = Query( + default=None, + description="ISO alpha-2 country code to filter companion rows.", + ), ) -> BansByCountryResponse: """Return ban counts aggregated by ISO country code. @@ -173,6 +183,7 @@ async def get_bans_by_country( geo_batch_lookup=geo_service.lookup_batch, app_db=request.app.state.db, origin=origin, + country_code=country_code, ) @@ -185,7 +196,10 @@ async def get_ban_trend( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query( + default="fail2ban", + description="Data source: 'fail2ban' or 'archive'.", + ), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", @@ -235,7 +249,10 @@ async def get_bans_by_jail( request: Request, _auth: AuthDep, range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), - source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."), + source: Literal["fail2ban", "archive"] = Query( + default="fail2ban", + description="Data source: 'fail2ban' or 'archive'.", + ), origin: BanOrigin | None = Query( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index 74228fe..bc0f214 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -15,7 +15,7 @@ Routes from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: import aiohttp @@ -56,7 +56,7 @@ async def get_history( default=None, description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", ), - source: str = Query( + source: Literal["fail2ban", "archive"] = Query( default="fail2ban", description="Data source: 'fail2ban' or 'archive'.", ), diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index 07ae406..57a1e74 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -290,6 +290,7 @@ async def bans_by_country( geo_enricher: GeoEnricher | None = None, app_db: aiosqlite.Connection | None = None, origin: BanOrigin | None = None, + country_code: str | None = None, ) -> BansByCountryResponse: """Aggregate ban counts per country for the selected time window. @@ -350,16 +351,6 @@ async def bans_by_country( total = len(all_rows) - # companion rows for the table should be most recent - companion_rows, _ = await get_archived_history( - db=app_db, - since=since, - origin=origin, - action="ban", - page=1, - page_size=_MAX_COMPANION_BANS, - ) - agg_rows = {} for row in all_rows: ip = str(row["ip"]) @@ -393,14 +384,6 @@ async def bans_by_country( origin=origin, ) - companion_rows, _ = await fail2ban_db_repo.get_currently_banned( - db_path=db_path, - since=since, - origin=origin, - limit=_MAX_COMPANION_BANS, - offset=0, - ) - unique_ips = [r.ip for r in agg_rows] geo_map: dict[str, GeoInfo] = {} @@ -434,6 +417,54 @@ async def bans_by_country( results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips)) geo_map = {ip: geo for ip, geo in results if geo is not None} + companion_rows: list[dict[str, object] | fail2ban_db_repo.BanRecord] + if country_code is None: + if source == "archive": + companion_rows, _ = await get_archived_history( + db=app_db, + since=since, + origin=origin, + action="ban", + page=1, + page_size=_MAX_COMPANION_BANS, + ) + else: + companion_rows, _ = await fail2ban_db_repo.get_currently_banned( + db_path=db_path, + since=since, + origin=origin, + limit=_MAX_COMPANION_BANS, + offset=0, + ) + else: + matched_ips = [ + ip + for ip, geo in geo_map.items() + if geo is not None and geo.country_code == country_code + ] + + if source == "archive": + if matched_ips: + companion_rows = await get_all_archived_history( + db=app_db, + since=since, + origin=origin, + action="ban", + ip_filter=matched_ips, + ) + else: + companion_rows = [] + else: + if matched_ips: + companion_rows, _ = await fail2ban_db_repo.get_currently_banned( + db_path=db_path, + since=since, + origin=origin, + ip_filter=matched_ips, + ) + else: + companion_rows = [] + # Build country aggregation from the SQL-grouped rows. countries: dict[str, int] = {} country_names: dict[str, str] = {} diff --git a/backend/app/tasks/history_sync.py b/backend/app/tasks/history_sync.py index b6ea3d3..17d48fd 100644 --- a/backend/app/tasks/history_sync.py +++ b/backend/app/tasks/history_sync.py @@ -26,7 +26,7 @@ JOB_ID: str = "history_sync" HISTORY_SYNC_INTERVAL: int = 300 #: Backfill window when archive is empty (seconds). -BACKFILL_WINDOW: int = 7 * 86400 +BACKFILL_WINDOW: int = 648000 async def _get_last_archive_ts(db) -> int | None: @@ -50,7 +50,7 @@ async def _run_sync(app: FastAPI) -> None: log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW) per_page = 500 - next_since = last_ts + next_since = last_ts + 1 total_synced = 0 while True: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 12dcce8..4caa38f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bangui-backend" -version = "0.9.15" +version = "0.9.18" description = "BanGUI backend — fail2ban web management interface" requires-python = ">=3.12" dependencies = [ diff --git a/backend/tests/test_repositories/test_fail2ban_db_repo.py b/backend/tests/test_repositories/test_fail2ban_db_repo.py index 98146bc..5f0c429 100644 --- a/backend/tests/test_repositories/test_fail2ban_db_repo.py +++ b/backend/tests/test_repositories/test_fail2ban_db_repo.py @@ -80,6 +80,32 @@ async def test_get_currently_banned_filters_and_pagination(tmp_path: Path) -> No assert records[0].ip == "3.3.3.3" +@pytest.mark.asyncio +async def test_get_currently_banned_filters_by_ip_list(tmp_path: Path) -> None: + db_path = str(tmp_path / "fail2ban.db") + async with aiosqlite.connect(db_path) as db: + await _create_bans_table(db) + await db.executemany( + "INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)", + [ + ("jail1", "1.1.1.1", 10, 1, "{}"), + ("jail1", "2.2.2.2", 20, 1, "{}"), + ("jail1", "3.3.3.3", 30, 1, "{}"), + ], + ) + await db.commit() + + records, total = await fail2ban_db_repo.get_currently_banned( + db_path=db_path, + since=0, + ip_filter=["2.2.2.2", "3.3.3.3"], + ) + + assert total == 2 + assert len(records) == 2 + assert {record.ip for record in records} == {"2.2.2.2", "3.3.3.3"} + + @pytest.mark.asyncio async def test_get_ban_counts_by_bucket_ignores_out_of_range_buckets(tmp_path: Path) -> None: db_path = str(tmp_path / "fail2ban.db") diff --git a/backend/tests/test_repositories/test_history_archive_repo.py b/backend/tests/test_repositories/test_history_archive_repo.py index c10997f..f9b5373 100644 --- a/backend/tests/test_repositories/test_history_archive_repo.py +++ b/backend/tests/test_repositories/test_history_archive_repo.py @@ -47,6 +47,10 @@ async def test_get_archived_history_filtering_and_pagination(app_db: str) -> Non assert total == 2 assert len(rows) == 1 + rows, total = await get_archived_history(db, ip_filter=["2.2.2.2"]) + assert total == 1 + assert rows[0]["ip"] == "2.2.2.2" + @pytest.mark.asyncio async def test_purge_archived_history(app_db: str) -> None: diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py index 20bcade..80e74ab 100644 --- a/backend/tests/test_routers/test_dashboard.py +++ b/backend/tests/test_routers/test_dashboard.py @@ -428,6 +428,15 @@ class TestBansByCountry: called_range = mock_fn.call_args[0][1] assert called_range == "7d" + async def test_invalid_source_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid source value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/by-country?source=invalid" + ) + assert response.status_code == 422 + async def test_empty_window_returns_empty_response( self, dashboard_client: AsyncClient ) -> None: @@ -513,6 +522,19 @@ class TestDashboardBansOriginField: assert mock_fn.call_args[1]["source"] == "archive" + async def test_bans_by_country_country_code_forwarded( + self, dashboard_client: AsyncClient + ) -> None: + """The ``country_code`` query parameter is forwarded to bans_by_country.""" + mock_fn = AsyncMock(return_value=_make_bans_by_country_response()) + with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn): + await dashboard_client.get( + "/api/dashboard/bans/by-country?country_code=DE" + ) + + _, kwargs = mock_fn.call_args + assert kwargs.get("country_code") == "DE" + async def test_blocklist_origin_serialised_correctly( self, dashboard_client: AsyncClient ) -> None: @@ -722,6 +744,15 @@ class TestBanTrend: ) assert response.status_code == 422 + async def test_invalid_source_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid source value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/trend?source=invalid" + ) + assert response.status_code == 422 + async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None: """Empty bucket list is serialised correctly.""" from app.models.ban import BanTrendResponse @@ -857,6 +888,15 @@ class TestBansByJail: ) assert response.status_code == 422 + async def test_invalid_source_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid source value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/by-jail?source=invalid" + ) + assert response.status_code == 422 + async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None: """Empty jails list is serialised correctly.""" from app.models.ban import BansByJailResponse diff --git a/backend/tests/test_services/test_ban_service.py b/backend/tests/test_services/test_ban_service.py index bf5cefd..87be876 100644 --- a/backend/tests/test_services/test_ban_service.py +++ b/backend/tests/test_services/test_ban_service.py @@ -654,6 +654,54 @@ class TestOriginFilter: assert result.total == 3 + async def test_bans_by_country_country_code_returns_all_matched_rows( + self, tmp_path: Path + ) -> None: + """``bans_by_country`` returns all companion rows for the selected country.""" + path = str(tmp_path / "fail2ban_country_filter.sqlite3") + rows = [ + { + "jail": "sshd", + "ip": "10.0.0.1", + "timeofban": _ONE_HOUR_AGO - i, + "bantime": 3600, + "bancount": 1, + "data": {"matches": ["failed login"]}, + } + for i in range(205) + ] + await _create_f2b_db(path, rows) + + from app.services import geo_service + + geo_service._cache["10.0.0.1"] = geo_service.GeoInfo( + country_code="DE", + country_name="Germany", + asn=None, + org=None, + ) + + with patch( + "app.services.ban_service.get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ), patch( + "app.services.ban_service.asyncio.create_task" + ) as mock_create_task: + result = await ban_service.bans_by_country( + "/fake/sock", + "24h", + country_code="DE", + http_session=AsyncMock(), + geo_cache_lookup=geo_service.lookup_cached_only, + ) + + mock_create_task.assert_not_called() + assert result.total == 205 + assert len(result.bans) == 205 + assert all(b.country_code == "DE" for b in result.bans) + + geo_service.clear_cache() + async def test_bans_by_country_source_archive_reads_archive( self, app_db_with_archive: aiosqlite.Connection ) -> None: diff --git a/backend/tests/test_tasks/test_history_sync.py b/backend/tests/test_tasks/test_history_sync.py index c9e1c44..de167d6 100644 --- a/backend/tests/test_tasks/test_history_sync.py +++ b/backend/tests/test_tasks/test_history_sync.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from app.tasks import history_sync @@ -27,3 +27,33 @@ class TestHistorySyncTask: called_args, called_kwargs = fake_scheduler.add_job.call_args assert called_kwargs["id"] == history_sync.JOB_ID assert called_kwargs["kwargs"]["app"] == app + + async def test_backfill_window_is_7_5_days(self) -> None: + assert history_sync.BACKFILL_WINDOW == 648000 + + async def test_sync_uses_strict_since_after_restart(self) -> None: + fake_app = type("FakeApp", (), {})() + fake_app.state = type("FakeState", (), {})() + fake_app.state.settings = type("FakeSettings", (), {})() + fake_app.state.settings.fail2ban_socket = "/tmp/fake.sock" + + fake_app.state.db = MagicMock() + + async def fake_get_history_page(*, db_path: str, since: int, page: int, page_size: int, **kwargs): + assert since == 1001 + return [], 0 + + async def fake_get_fail2ban_db_path(socket_path: str) -> str: + return "/tmp/fake.sqlite3" + + with patch( + "app.tasks.history_sync._get_last_archive_ts", + new=AsyncMock(return_value=1000), + ), patch( + "app.tasks.history_sync.get_fail2ban_db_path", + new=fake_get_fail2ban_db_path, + ), patch( + "app.tasks.history_sync.fail2ban_db_repo.get_history_page", + new=fake_get_history_page, + ): + await history_sync._run_sync(fake_app) diff --git a/fail2ban-master/config/fail2ban.conf b/fail2ban-master/config/fail2ban.conf index fd6baeb..080031c 100644 --- a/fail2ban-master/config/fail2ban.conf +++ b/fail2ban-master/config/fail2ban.conf @@ -72,7 +72,7 @@ dbfile = /var/lib/fail2ban/fail2ban.sqlite3 # Options: dbpurgeage # Notes.: Sets age at which bans should be purged from the database # Values: [ SECONDS ] Default: 86400 (24hours) -dbpurgeage = 1d +dbpurgeage = 648000 # Options: dbmaxmatches # Notes.: Number of matches stored in database per ticket (resolvable via diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 35bb71d..150b233 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,30 +1,33 @@ { "name": "bangui-frontend", - "version": "0.9.14", + "version": "0.9.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bangui-frontend", - "version": "0.9.14", + "version": "0.9.18", "dependencies": { "@fluentui/react-components": "^9.55.0", "@fluentui/react-icons": "^2.0.257", - "@types/react-simple-maps": "^3.0.6", + "d3-geo": "^3.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", - "react-simple-maps": "^3.0.0", - "recharts": "^3.8.0" + "recharts": "^3.8.0", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2" }, "devDependencies": { "@eslint/js": "^9.13.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/d3-geo": "^3.1.0", "@types/node": "^25.3.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/topojson-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/parser": "^8.13.0", "@vitejs/plugin-react": "^4.3.3", @@ -3565,23 +3568,15 @@ "license": "MIT" }, "node_modules/@types/d3-geo": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz", - "integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" } }, - "node_modules/@types/d3-interpolate": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz", - "integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "^2" - } - }, "node_modules/@types/d3-path": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", @@ -3597,12 +3592,6 @@ "@types/d3-time": "*" } }, - "node_modules/@types/d3-selection": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz", - "integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==", - "license": "MIT" - }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -3624,16 +3613,6 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, - "node_modules/@types/d3-zoom": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz", - "integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "^2", - "@types/d3-selection": "^2" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3652,6 +3631,7 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -3696,16 +3676,25 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/react-simple-maps": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz", - "integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==", + "node_modules/@types/topojson-client": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz", + "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/d3-geo": "^2", - "@types/d3-zoom": "^2", "@types/geojson": "*", - "@types/react": "*" + "@types/topojson-specification": "*" + } + }, + "node_modules/@types/topojson-specification": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz", + "integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" } }, "node_modules/@types/use-sync-external-store": { @@ -4476,28 +4465,6 @@ "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", "license": "BSD-3-Clause" }, - "node_modules/d3-dispatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", - "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-drag": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", - "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1 - 2", - "d3-selection": "2" - } - }, - "node_modules/d3-ease": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", - "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==", - "license": "BSD-3-Clause" - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -4508,12 +4475,15 @@ } }, "node_modules/d3-geo": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", - "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", - "license": "BSD-3-Clause", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", "dependencies": { - "d3-array": "^2.5.0" + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" } }, "node_modules/d3-interpolate": { @@ -4550,12 +4520,6 @@ "node": ">=12" } }, - "node_modules/d3-selection": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", - "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==", - "license": "BSD-3-Clause" - }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -4592,41 +4556,6 @@ "node": ">=12" } }, - "node_modules/d3-timer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", - "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-transition": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", - "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1 - 2", - "d3-dispatch": "1 - 2", - "d3-ease": "1 - 2", - "d3-interpolate": "1 - 2", - "d3-timer": "1 - 2" - }, - "peerDependencies": { - "d3-selection": "2" - } - }, - "node_modules/d3-zoom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", - "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1 - 2", - "d3-drag": "2", - "d3-interpolate": "1 - 2", - "d3-selection": "2", - "d3-transition": "2" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -5745,16 +5674,6 @@ "dev": true, "license": "MIT" }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -5982,18 +5901,6 @@ "license": "MIT", "peer": true }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6110,23 +6017,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-simple-maps": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", - "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==", - "license": "MIT", - "dependencies": { - "d3-geo": "^2.0.2", - "d3-selection": "^2.0.0", - "d3-zoom": "^2.0.0", - "topojson-client": "^3.1.0" - }, - "peerDependencies": { - "prop-types": "^15.7.2", - "react": "^16.8.0 || 17.x || 18.x", - "react-dom": "^16.8.0 || 17.x || 18.x" - } - }, "node_modules/recharts": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", @@ -7516,6 +7406,12 @@ "node": ">=0.10.0" } }, + "node_modules/world-atlas": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz", + "integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 27ebe1e..619cf07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "bangui-frontend", "private": true, - "version": "0.9.15", + "version": "0.9.18", "description": "BanGUI frontend — fail2ban web management interface", "type": "module", "scripts": { @@ -17,14 +17,17 @@ "dependencies": { "@fluentui/react-components": "^9.55.0", "@fluentui/react-icons": "^2.0.257", - "@types/react-simple-maps": "^3.0.6", + "d3-geo": "^3.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", - "react-simple-maps": "^3.0.0", + "topojson-client": "^3.1.0", + "world-atlas": "^2.0.2", "recharts": "^3.8.0" }, "devDependencies": { + "@types/d3-geo": "^3.1.0", + "@types/topojson-client": "^3.0.0", "@eslint/js": "^9.13.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 90309d8..61e429e 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -42,6 +42,7 @@ export async function fetchBans( page = 1, pageSize = 100, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): Promise { const params = new URLSearchParams({ range, @@ -51,6 +52,9 @@ export async function fetchBans( if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } return get(`${ENDPOINTS.dashboardBans}?${params.toString()}`); } @@ -66,11 +70,15 @@ export async function fetchBans( export async function fetchBanTrend( range: TimeRange, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): Promise { const params = new URLSearchParams({ range }); if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } return get(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`); } @@ -86,10 +94,14 @@ export async function fetchBanTrend( export async function fetchBansByJail( range: TimeRange, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): Promise { const params = new URLSearchParams({ range }); if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } return get(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`); } diff --git a/frontend/src/api/history.ts b/frontend/src/api/history.ts index e318a5a..f239317 100644 --- a/frontend/src/api/history.ts +++ b/frontend/src/api/history.ts @@ -21,6 +21,7 @@ export async function fetchHistory( if (query.origin) params.set("origin", query.origin); if (query.jail) params.set("jail", query.jail); if (query.ip) params.set("ip", query.ip); + if (query.source) params.set("source", query.source); if (query.page !== undefined) params.set("page", String(query.page)); if (query.page_size !== undefined) params.set("page_size", String(query.page_size)); diff --git a/frontend/src/api/map.test.ts b/frontend/src/api/map.test.ts new file mode 100644 index 0000000..f561b71 --- /dev/null +++ b/frontend/src/api/map.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import { ENDPOINTS } from "./endpoints"; +import { fetchBansByCountry } from "./map"; +import { get } from "./client"; + +vi.mock("./client", () => ({ + get: vi.fn(), +})); + +const mockedGet = get as Mock; + +describe("fetchBansByCountry", () => { + beforeEach(() => { + mockedGet.mockReset(); + mockedGet.mockResolvedValue({ countries: {}, country_names: {}, bans: [], total: 0 }); + }); + + it("appends country_code when provided", async () => { + await fetchBansByCountry("24h", "all", "fail2ban", "US"); + + expect(get).toHaveBeenCalledWith( + `${ENDPOINTS.dashboardBansByCountry}?range=24h&country_code=US` + ); + }); + + it("does not append country_code when undefined", async () => { + await fetchBansByCountry("24h", "all", "fail2ban"); + + expect(get).toHaveBeenCalledWith( + `${ENDPOINTS.dashboardBansByCountry}?range=24h` + ); + }); +}); diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts index e6b8eda..086217d 100644 --- a/frontend/src/api/map.ts +++ b/frontend/src/api/map.ts @@ -17,10 +17,18 @@ import type { BanOriginFilter } from "../types/ban"; export async function fetchBansByCountry( range: TimeRange = "24h", origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", + countryCode?: string, ): Promise { const params = new URLSearchParams({ range }); if (origin !== "all") { params.set("origin", origin); } + if (source !== "fail2ban") { + params.set("source", source); + } + if (countryCode) { + params.set("country_code", countryCode); + } return get(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`); } diff --git a/frontend/src/components/BanTable.tsx b/frontend/src/components/BanTable.tsx index bff6164..85879ac 100644 --- a/frontend/src/components/BanTable.tsx +++ b/frontend/src/components/BanTable.tsx @@ -46,6 +46,10 @@ interface BanTableProps { * Changing this value triggers a re-fetch and resets to page 1. */ origin?: BanOriginFilter; + /** + * Data source used for the table query. + */ + source?: "fail2ban" | "archive"; } // --------------------------------------------------------------------------- @@ -186,9 +190,9 @@ function buildBanColumns(styles: ReturnType): TableColumnDefin * @param props.timeRange - Active time-range preset from the parent page. * @param props.origin - Active origin filter from the parent page. */ -export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element { +export function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element { const styles = useStyles(); - const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin); + const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin, source); const banColumns = buildBanColumns(styles); diff --git a/frontend/src/components/BanTrendChart.tsx b/frontend/src/components/BanTrendChart.tsx index 9f56586..32dbfaa 100644 --- a/frontend/src/components/BanTrendChart.tsx +++ b/frontend/src/components/BanTrendChart.tsx @@ -53,6 +53,8 @@ interface BanTrendChartProps { timeRange: TimeRange; /** Origin filter controlling which bans are included. */ origin: BanOriginFilter; + /** Data source used for the chart. */ + source?: "fail2ban" | "archive"; } /** Internal chart data point shape. */ @@ -188,9 +190,10 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null { export function BanTrendChart({ timeRange, origin, + source = "fail2ban", }: BanTrendChartProps): React.JSX.Element { const styles = useStyles(); - const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin); + const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin, source); const isEmpty = buckets.every((b) => b.count === 0); const entries = buildEntries(buckets, timeRange); diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index 21541f3..e102790 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -1,31 +1,42 @@ /** * WorldMap — SVG world map showing per-country ban counts. * - * Uses react-simple-maps with the Natural Earth 110m TopoJSON data from - * jsDelivr CDN. For each country that has bans in the selected time window, - * the total count is displayed inside the country's borders. Clicking a - * country filters the companion table. + * Uses a local TopoJSON bundle and d3-geo for projection, path generation, + * and native SVG pan/zoom behaviour. */ import { createPortal } from "react-dom"; -import { useCallback, useState } from "react"; -import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Button, makeStyles, tokens } from "@fluentui/react-components"; +import { geoMercator, geoPath, type GeoPath } from "d3-geo"; +import { feature } from "topojson-client"; +import type { + Feature, + FeatureCollection, + GeoJsonProperties, + Geometry, +} from "geojson"; +import type { + GeometryCollection as TopoGeometryCollection, + Topology, +} from "topojson-specification"; +import worldData from "world-atlas/countries-110m.json"; import { useCardStyles } from "../theme/commonStyles"; -import type { GeoPermissibleObjects } from "d3-geo"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; import { getBanCountColor } from "../utils/mapColors"; -// --------------------------------------------------------------------------- -// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only) -// --------------------------------------------------------------------------- - -const GEO_URL = - "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; - -// --------------------------------------------------------------------------- -// Styles -// --------------------------------------------------------------------------- +const MAP_WIDTH = 800; +const MAP_HEIGHT = 400; +const MIN_ZOOM = 1; +const MAX_ZOOM = 8; +const ZOOM_STEP = 0.5; +const PAN_THRESHOLD = 3; const useStyles = makeStyles({ mapWrapper: { @@ -33,6 +44,25 @@ const useStyles = makeStyles({ position: "relative", overflow: "hidden", }, + svg: { + width: "100%", + height: "auto", + touchAction: "none", + }, + country: { + transition: "fill 150ms ease, stroke 150ms ease", + stroke: tokens.colorNeutralStroke2, + strokeWidth: 0.75, + fill: "var(--country-fill)", + outline: "none", + cursor: "pointer", + }, + countryHovered: { + fill: "var(--country-hover-fill)", + }, + countrySelected: { + fill: "var(--country-selected-fill)", + }, countLabel: { fontSize: "9px", fontWeight: "600", @@ -73,194 +103,21 @@ const useStyles = makeStyles({ }, }); -// --------------------------------------------------------------------------- -// GeoLayer — must be rendered inside ComposableMap to access map context -// --------------------------------------------------------------------------- +type TopoJsonTopology = Topology & { + objects: { + countries: TopoGeometryCollection; + }; +}; -interface GeoLayerProps { - countries: Record; - countryNames?: Record; - selectedCountry: string | null; - onSelectCountry: (cc: string | null) => void; - thresholdLow: number; - thresholdMedium: number; - thresholdHigh: number; -} +type TooltipState = { + cc: string; + count: number; + name: string; + x: number; + y: number; +} | null; -function GeoLayer({ - countries, - countryNames, - selectedCountry, - onSelectCountry, - thresholdLow, - thresholdMedium, - thresholdHigh, -}: GeoLayerProps): React.JSX.Element { - const styles = useStyles(); - const { geographies, path } = useGeographies({ geography: GEO_URL }); - - const [tooltip, setTooltip] = useState< - | { - cc: string; - count: number; - name: string; - x: number; - y: number; - } - | null - >(null); - - const handleClick = useCallback( - (cc: string | null): void => { - onSelectCountry(selectedCountry === cc ? null : cc); - }, - [selectedCountry, onSelectCountry], - ); - - if (geographies.length === 0) return <>; - - // react-simple-maps types declare path as always defined, but it can be null - // during initial render before MapProvider context initializes. Cast to reflect - // the true runtime type and allow safe null checking. - const safePath = path as unknown as typeof path | null; - - return ( - <> - {(geographies as { rsmKey: string; id: string | number }[]).map( - (geo) => { - const numericId = String(geo.id); - const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; - const count: number = cc !== null ? (countries[cc] ?? 0) : 0; - const isSelected = cc !== null && selectedCountry === cc; - - // Compute the fill color based on ban count - const fillColor = getBanCountColor( - count, - thresholdLow, - thresholdMedium, - thresholdHigh, - ); - - // Only calculate centroid if path is available - let cx: number | undefined; - let cy: number | undefined; - if (safePath != null) { - const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects); - [cx, cy] = centroid; - } - - return ( - - { - if (cc) handleClick(cc); - }} - onKeyDown={(e): void => { - if (cc && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - handleClick(cc); - } - }} - onMouseEnter={(e): void => { - if (!cc) return; - setTooltip({ - cc, - count, - name: countryNames?.[cc] ?? cc, - x: e.clientX, - y: e.clientY, - }); - }} - onMouseMove={(e): void => { - setTooltip((current) => - current - ? { - ...current, - x: e.clientX, - y: e.clientY, - } - : current, - ); - }} - onMouseLeave={(): void => { - setTooltip(null); - }} - style={{ - default: { - fill: isSelected ? tokens.colorBrandBackground : fillColor, - stroke: tokens.colorNeutralStroke2, - strokeWidth: 0.75, - outline: "none", - }, - hover: { - fill: isSelected - ? tokens.colorBrandBackgroundHover - : cc && count > 0 - ? tokens.colorNeutralBackground3 - : fillColor, - stroke: tokens.colorNeutralStroke1, - strokeWidth: 1, - outline: "none", - }, - pressed: { - fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, - stroke: tokens.colorBrandStroke1, - strokeWidth: 1, - outline: "none", - }, - }} - /> - {count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && ( - - {count} - - )} - - ); - }, - )} - - {tooltip && - createPortal( -
- {tooltip.name} - - {tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""} - -
, - document.body, - )} - - ); -} - -// --------------------------------------------------------------------------- -// WorldMap — public component -// --------------------------------------------------------------------------- - -export interface WorldMapProps { +interface WorldMapProps { /** ISO alpha-2 country code → ban count. */ countries: Record; /** Optional mapping from country code to display name. */ @@ -288,21 +145,143 @@ export function WorldMap({ }: WorldMapProps): React.JSX.Element { const styles = useStyles(); const cardStyles = useCardStyles(); - const [zoom, setZoom] = useState(1); + const [zoom, setZoom] = useState(MIN_ZOOM); const [center, setCenter] = useState<[number, number]>([0, 0]); + const [hoveredCountry, setHoveredCountry] = useState(null); + const [tooltip, setTooltip] = useState(null); - const handleZoomIn = (): void => { - setZoom((z) => Math.min(z + 0.5, 8)); - }; + const zoomRef = useRef(zoom); + const centerRef = useRef<[number, number]>(center); + const dragStateRef = useRef<{ + active: boolean; + startX: number; + startY: number; + startCenter: [number, number]; + moved: boolean; + } | null>(null); + const clickSuppressedRef = useRef(false); - const handleZoomOut = (): void => { - setZoom((z) => Math.max(z - 0.5, 1)); - }; + useEffect(() => { + zoomRef.current = zoom; + }, [zoom]); - const handleResetView = (): void => { - setZoom(1); + useEffect(() => { + centerRef.current = center; + }, [center]); + + const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []); + + const geoJson = useMemo( + () => + feature(topology, topology.objects.countries) as FeatureCollection< + Geometry, + GeoJsonProperties + >, + [topology], + ); + + const projection = useMemo( + () => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson), + [geoJson], + ); + + const pathGenerator = useMemo>>( + () => geoPath().projection(projection), + [projection], + ); + + const countryFeatures = useMemo( + () => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null), + [geoJson.features], + ); + + const clampZoom = useCallback((value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM), []); + + const handleCountrySelect = useCallback( + (cc: string | null): void => { + if (clickSuppressedRef.current) { + return; + } + + onSelectCountry(selectedCountry === cc ? null : cc); + }, + [onSelectCountry, selectedCountry], + ); + + const handlePointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return; + + event.currentTarget.setPointerCapture(event.pointerId); + dragStateRef.current = { + active: true, + startX: event.clientX, + startY: event.clientY, + startCenter: centerRef.current, + moved: false, + }; + clickSuppressedRef.current = false; + }, []); + + const handlePointerMove = useCallback((event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (!drag?.active) return; + + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) { + drag.moved = true; + clickSuppressedRef.current = true; + } + + setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]); + }, []); + + const handlePointerUp = useCallback((event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (!drag) return; + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + dragStateRef.current = null; + window.setTimeout(() => { + clickSuppressedRef.current = false; + }, 0); + }, []); + + const handleWheel = useCallback((event: React.WheelEvent) => { + event.preventDefault(); + + const currentZoom = zoomRef.current; + const desiredZoom = clampZoom(currentZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)); + if (desiredZoom === currentZoom) { + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + const svgX = (event.clientX - rect.left - centerRef.current[0]) / currentZoom; + const svgY = (event.clientY - rect.top - centerRef.current[1]) / currentZoom; + + setZoom(desiredZoom); + setCenter([ + centerRef.current[0] - svgX * (desiredZoom - currentZoom), + centerRef.current[1] - svgY * (desiredZoom - currentZoom), + ]); + }, [clampZoom]); + + const handleZoomIn = useCallback(() => { + setZoom((value) => clampZoom(value + ZOOM_STEP)); + }, [clampZoom]); + + const handleZoomOut = useCallback(() => { + setZoom((value) => clampZoom(value - ZOOM_STEP)); + }, [clampZoom]); + + const handleResetView = useCallback(() => { + setZoom(MIN_ZOOM); setCenter([0, 0]); - }; + }, []); return (
- {/* Zoom controls */}
- - { - setZoom(newZoom); - setCenter(coordinates); - }} - minZoom={1} - maxZoom={8} - > - - - + + {countryFeatures.map((featureItem) => { + const rawId = featureItem.id; + const numericId = String(Number(rawId)); + const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; + const count = cc !== null ? countries[cc] ?? 0 : 0; + const isSelected = cc !== null && selectedCountry === cc; + const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh); + const pathString = pathGenerator(featureItem) ?? ""; + if (!pathString) { + return null; + } + + return ( + + { + if (cc) { + handleCountrySelect(cc); + } + }} + onKeyDown={(event): void => { + if (cc && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + handleCountrySelect(cc); + } + }} + onMouseEnter={(event): void => { + if (!cc) return; + setHoveredCountry(cc); + setTooltip({ + cc, + count, + name: countryNames?.[cc] ?? cc, + x: event.clientX, + y: event.clientY, + }); + }} + onMouseMove={(event): void => { + setTooltip((current) => + current + ? { ...current, x: event.clientX, y: event.clientY } + : current, + ); + }} + onMouseLeave={(): void => { + setHoveredCountry(null); + setTooltip(null); + }} + /> + + ); + })} + + + + {tooltip && + createPortal( +
+ {tooltip.name} + + {tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""} + +
, + document.body, + )}
); } diff --git a/frontend/src/components/__tests__/WorldMap.test.tsx b/frontend/src/components/__tests__/WorldMap.test.tsx index 4d70cda..9980b76 100644 --- a/frontend/src/components/__tests__/WorldMap.test.tsx +++ b/frontend/src/components/__tests__/WorldMap.test.tsx @@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; -// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry. -vi.mock("react-simple-maps", () => ({ - ComposableMap: ({ children }: { children: React.ReactNode }) =>
{children}
, - ZoomableGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, - Geography: ({ children, ...props }: { children?: React.ReactNode } & Record) => {children}, - useGeographies: () => ({ - geographies: [{ rsmKey: "geo-1", id: 840 }], - path: { centroid: () => [10, 10] }, +vi.mock( + "world-atlas/countries-110m.json", + () => ({ + default: { + type: "Topology", + objects: { + countries: { + type: "GeometryCollection", + geometries: [ + { + type: "Polygon", + arcs: [[0]], + id: "840", + }, + ], + }, + }, + arcs: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], + transform: { + scale: [1, 1], + translate: [0, 0], + }, + }, }), -})); +); import { WorldMap } from "../WorldMap"; @@ -34,19 +49,20 @@ describe("WorldMap", () => { , ); - // Tooltip should not be present initially expect(screen.queryByRole("tooltip")).toBeNull(); - // Country map area is exposed as an accessible button with an accurate label const countryButton = screen.getByRole("button", { name: "US: 42 bans" }); expect(countryButton).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Zoom in/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Zoom out/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Reset view/i })).toBeInTheDocument(); + fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 }); const tooltip = screen.getByRole("tooltip"); expect(tooltip).toHaveTextContent("United States"); expect(tooltip).toHaveTextContent("42 bans"); - expect(tooltip).toHaveStyle({ left: "22px", top: "22px" }); fireEvent.mouseLeave(countryButton); expect(screen.queryByRole("tooltip")).toBeNull(); diff --git a/frontend/src/data/isoNumericToAlpha2.ts b/frontend/src/data/isoNumericToAlpha2.ts index cca5dc2..b623bfa 100644 --- a/frontend/src/data/isoNumericToAlpha2.ts +++ b/frontend/src/data/isoNumericToAlpha2.ts @@ -8,6 +8,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "4": "AF", "8": "AL", + "10": "AQ", "12": "DZ", "16": "AS", "20": "AD", @@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "148": "TD", "152": "CL", "156": "CN", + "158": "TW", "162": "CX", "166": "CC", "170": "CO", @@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "250": "FR", "254": "GF", "258": "PF", + "260": "TF", "262": "DJ", "266": "GA", "268": "GE", @@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "372": "IE", "376": "IL", "380": "IT", + "384": "CI", "388": "JM", "392": "JP", "398": "KZ", diff --git a/frontend/src/hooks/useBanTrend.ts b/frontend/src/hooks/useBanTrend.ts index 2e45660..b258081 100644 --- a/frontend/src/hooks/useBanTrend.ts +++ b/frontend/src/hooks/useBanTrend.ts @@ -42,6 +42,7 @@ export interface UseBanTrendResult { export function useBanTrend( timeRange: TimeRange, origin: BanOriginFilter, + source: "fail2ban" | "archive" = "fail2ban", ): UseBanTrendResult { const [buckets, setBuckets] = useState([]); const [bucketSize, setBucketSize] = useState("1h"); @@ -58,7 +59,7 @@ export function useBanTrend( setIsLoading(true); setError(null); - fetchBanTrend(timeRange, origin) + fetchBanTrend(timeRange, origin, source) .then((data) => { if (controller.signal.aborted) return; setBuckets(data.buckets); @@ -73,7 +74,7 @@ export function useBanTrend( setIsLoading(false); } }); - }, [timeRange, origin]); + }, [timeRange, origin, source]); useEffect(() => { load(); diff --git a/frontend/src/hooks/useBans.ts b/frontend/src/hooks/useBans.ts index 9e36f45..c51471c 100644 --- a/frontend/src/hooks/useBans.ts +++ b/frontend/src/hooks/useBans.ts @@ -44,6 +44,7 @@ export interface UseBansResult { export function useBans( timeRange: TimeRange, origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", ): UseBansResult { const [banItems, setBanItems] = useState([]); const [total, setTotal] = useState(0); @@ -51,16 +52,16 @@ export function useBans( const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Reset page when time range or origin filter changes. + // Reset page when time range, origin filter, or source changes. useEffect(() => { setPage(1); - }, [timeRange, origin]); + }, [timeRange, origin, source]); const doFetch = useCallback(async (): Promise => { setLoading(true); setError(null); try { - const data = await fetchBans(timeRange, page, PAGE_SIZE, origin); + const data = await fetchBans(timeRange, page, PAGE_SIZE, origin, source); setBanItems(data.items); setTotal(data.total); } catch (err: unknown) { @@ -68,7 +69,7 @@ export function useBans( } finally { setLoading(false); } - }, [timeRange, page, origin]); + }, [timeRange, page, origin, source]); // Stable ref to the latest doFetch so the refresh callback is always current. const doFetchRef = useRef(doFetch); diff --git a/frontend/src/hooks/useDashboardCountryData.ts b/frontend/src/hooks/useDashboardCountryData.ts index 250fcff..3c9e625 100644 --- a/frontend/src/hooks/useDashboardCountryData.ts +++ b/frontend/src/hooks/useDashboardCountryData.ts @@ -48,6 +48,7 @@ export interface UseDashboardCountryDataResult { export function useDashboardCountryData( timeRange: TimeRange, origin: BanOriginFilter, + source: "fail2ban" | "archive" = "fail2ban", ): UseDashboardCountryDataResult { const [countries, setCountries] = useState>({}); const [countryNames, setCountryNames] = useState>({}); @@ -67,7 +68,7 @@ export function useDashboardCountryData( setIsLoading(true); setError(null); - fetchBansByCountry(timeRange, origin) + fetchBansByCountry(timeRange, origin, source) .then((data) => { if (controller.signal.aborted) return; setCountries(data.countries); @@ -85,7 +86,7 @@ export function useDashboardCountryData( setIsLoading(false); } }); - }, [timeRange, origin]); + }, [timeRange, origin, source]); useEffect(() => { load(); diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index d4537e6..0597cfb 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -43,6 +43,8 @@ export interface UseMapDataResult { export function useMapData( range: TimeRange = "24h", origin: BanOriginFilter = "all", + source: "fail2ban" | "archive" = "fail2ban", + countryCode?: string, ): UseMapDataResult { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -64,7 +66,7 @@ export function useMapData( abortRef.current?.abort(); abortRef.current = new AbortController(); - fetchBansByCountry(range, origin) + fetchBansByCountry(range, origin, source, countryCode) .then((resp) => { setData(resp); }) @@ -75,7 +77,7 @@ export function useMapData( setLoading(false); }); }, DEBOUNCE_MS); - }, [range, origin]); + }, [range, origin, source, countryCode]); useEffect((): (() => void) => { load(); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index c22abcc..b8fe6a1 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -71,8 +71,10 @@ export function DashboardPage(): React.JSX.Element { const [timeRange, setTimeRange] = useState("24h"); const [originFilter, setOriginFilter] = useState("all"); + const source = timeRange === "24h" ? "fail2ban" : "archive"; + const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } = - useDashboardCountryData(timeRange, originFilter); + useDashboardCountryData(timeRange, originFilter, source); const sectionStyles = useCommonSectionStyles(); @@ -86,12 +88,14 @@ export function DashboardPage(): React.JSX.Element { {/* ------------------------------------------------------------------ */} {/* Global filter bar */} {/* ------------------------------------------------------------------ */} - +
+ +
{/* ------------------------------------------------------------------ */} {/* Ban Trend section */} @@ -103,7 +107,7 @@ export function DashboardPage(): React.JSX.Element {
- +
@@ -154,7 +158,7 @@ export function DashboardPage(): React.JSX.Element { {/* Ban table */}
- +
diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx index debdb85..37d289b 100644 --- a/frontend/src/pages/HistoryPage.tsx +++ b/frontend/src/pages/HistoryPage.tsx @@ -143,6 +143,7 @@ function areHistoryQueriesEqual( a.origin === b.origin && a.jail === b.jail && a.ip === b.ip && + a.source === b.source && a.page === b.page && a.page_size === b.page_size ); @@ -386,11 +387,12 @@ export function HistoryPage(): React.JSX.Element { const styles = useStyles(); // Filter state - const [range, setRange] = useState("24h"); + const [range, setRange] = useState("7d"); const [originFilter, setOriginFilter] = useState("all"); const [jailFilter, setJailFilter] = useState(""); const [ipFilter, setIpFilter] = useState(""); const [appliedQuery, setAppliedQuery] = useState({ + source: "archive", page_size: PAGE_SIZE, }); @@ -406,6 +408,7 @@ export function HistoryPage(): React.JSX.Element { origin: originFilter !== "all" ? originFilter : undefined, jail: jailFilter.trim() || undefined, ip: ipFilter.trim() || undefined, + source: "archive", page: 1, page_size: PAGE_SIZE, }; @@ -487,23 +490,6 @@ export function HistoryPage(): React.JSX.Element { /> - {/* ---------------------------------------------------------------- */} - {/* Error / loading state */} - {/* ---------------------------------------------------------------- */} - {error && ( - - {error} - - )} - - {loading && !error && ( -
- -
- )} - {/* ---------------------------------------------------------------- */} {/* Summary */} {/* ---------------------------------------------------------------- */} diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index 1b3fbdc..4cf0c2e 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -64,6 +64,13 @@ const useStyles = makeStyles({ borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorNeutralStroke1}`, }, + stickyHeaderCell: { + position: "sticky", + top: 0, + zIndex: 1, + backgroundColor: tokens.colorNeutralBackground1, + boxShadow: `0 1px 0 ${tokens.colorNeutralStroke2}`, + }, filterBar: { display: "flex", alignItems: "center", @@ -81,6 +88,9 @@ const useStyles = makeStyles({ padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, borderTop: `1px solid ${tokens.colorNeutralStroke2}`, backgroundColor: tokens.colorNeutralBackground2, + position: "sticky", + bottom: 0, + zIndex: 1, }, }); @@ -98,8 +108,10 @@ export function MapPage(): React.JSX.Element { const PAGE_SIZE_OPTIONS = [25, 50, 100] as const; + const source = range === "24h" ? "fail2ban" : "archive"; + const { countries, countryNames, bans, total, loading, error, refresh } = - useMapData(range, originFilter); + useMapData(range, originFilter, source, selectedCountry ?? undefined); const { thresholds: mapThresholds, @@ -246,12 +258,12 @@ export function MapPage(): React.JSX.Element { - IP Address - Jail - Banned At - Country - Origin - Times Banned + IP Address + Jail + Banned At + Country + Origin + Times Banned diff --git a/frontend/src/pages/__tests__/HistoryPage.test.tsx b/frontend/src/pages/__tests__/HistoryPage.test.tsx index 57afde5..87063a9 100644 --- a/frontend/src/pages/__tests__/HistoryPage.test.tsx +++ b/frontend/src/pages/__tests__/HistoryPage.test.tsx @@ -50,7 +50,8 @@ describe("HistoryPage", () => { // Initial load should include the auto-applied default query. await waitFor(() => { expect(lastQuery).toEqual({ - range: "24h", + range: "7d", + source: "archive", origin: undefined, jail: undefined, ip: undefined, diff --git a/frontend/src/types/history.ts b/frontend/src/types/history.ts index 209e2be..7488e83 100644 --- a/frontend/src/types/history.ts +++ b/frontend/src/types/history.ts @@ -57,6 +57,7 @@ export interface HistoryQuery { origin?: BanOriginFilter; jail?: string; ip?: string; + source?: "fail2ban" | "archive"; page?: number; page_size?: number; } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 0235e1d..04823da 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -8,6 +8,8 @@ /* Bundler mode */ "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force",