- Task 1: Mark imported blocklist IP addresses
- Add BanOrigin type and _derive_origin() to ban.py model
- Populate origin field in ban_service list_bans() and bans_by_country()
- BanTable and MapPage companion table show origin badge column
- Tests: origin derivation in test_ban_service.py and test_dashboard.py
- Task 2: Add origin filter to dashboard and world map
- ban_service: _origin_sql_filter() helper; origin param on list_bans()
and bans_by_country()
- dashboard router: optional origin query param forwarded to service
- Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in ban.ts
- fetchBans / fetchBansByCountry forward origin to API
- useBans / useMapData accept and pass origin; page resets on change
- BanTable accepts origin prop; DashboardPage adds segmented filter
- MapPage adds origin Select next to time-range picker
- Tests: origin filter assertions in test_ban_service and test_dashboard
15 KiB
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.
Task 1 — Mark Imported Blocklist IP Addresses ✅ DONE
Completed: Added origin field ("blocklist" / "selfblock") to Ban and DashboardBanItem models, derived from jail name in ban_service.py. BanTable and MapPage companion table display an Origin badge column. Tests added to test_ban_service.py and test_dashboard.py.
Problem
When IPs are imported from an external blocklist they are applied to the blocklist-import jail via the fail2ban socket. Once they land in the fail2ban SQLite bans table they look identical to IPs that were banned organically by fail2ban filters. There is no way for the dashboard or map to tell the user whether a given ban came from a blocklist import or from a real failed-login detection.
Goal
Every ban displayed in the UI must carry a visible origin indicator: either blocklist (imported from a blocklist source) or selfblock (detected by fail2ban itself).
Implementation Details
Backend — derive origin from jail name
The blocklist import service already uses a dedicated jail called blocklist-import. This can be used as the discriminator: any ban whose jail column equals blocklist-import is a blocklist ban; everything else is a self-block.
- Model change — In
backend/app/models/ban.py, add anoriginfield of typeLiteral["blocklist", "selfblock"]to bothBanandDashboardBanItem. Compute it from thejailvalue during construction (ifjail == "blocklist-import"→"blocklist", else"selfblock"). - Service change — In
backend/app/services/ban_service.py, make surelist_bans()andbans_by_country()populate the neworiginfield when building ban objects. - API response — The JSON payloads from
GET /api/dashboard/bansandGET /api/dashboard/bans/by-countryalready serialise every field ofDashboardBanItem, sooriginwill appear automatically once the model is updated.
Frontend — show origin badge
- BanTable — In
frontend/src/components/BanTable.tsx, add an Origin column. Render a small coloured badge: blue labelBlocklistor grey labelSelfblock. - MapPage detail table — The table below the world map in
frontend/src/pages/MapPage.tsxalso lists bans. Add the same origin badge column there.
Tests
- Add a unit test in
backend/tests/test_services/test_ban_service.pythat inserts bans into a mock fail2ban DB under both jail names (blocklist-importand e.g.sshd) and asserts the returned objects carry the correctoriginvalue. - Add a router test in
backend/tests/test_routers/test_dashboard.pythat verifies the JSON response contains theoriginfield.
Files Touched
| Layer | File |
|---|---|
| Model | backend/app/models/ban.py |
| Service | backend/app/services/ban_service.py |
| Frontend | frontend/src/components/BanTable.tsx |
| Frontend | frontend/src/pages/MapPage.tsx |
| Tests | backend/tests/test_services/test_ban_service.py |
| Tests | backend/tests/test_routers/test_dashboard.py |
Task 2 — Add Origin Filter to Dashboard and World Map ✅ DONE
Completed: Added optional origin query parameter ("blocklist" / "selfblock") to both dashboard router endpoints, filtered via jail-based WHERE clause in ban_service. Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in types/ban.ts; fetchBans and fetchBansByCountry APIs forward the param; useBans and useMapData hooks accept origin; DashboardPage and MapPage show a segmented toggle / select for All / Blocklist / Selfblock. Tests extended in test_ban_service.py and test_dashboard.py.
Problem
Once Task 1 exposes the origin field, users need a way to filter the view so they can see all bans, only blocklist bans, or only self-blocked bans. This must work on both the dashboard ban table and the world map.
Goal
Add a filter dropdown (or segmented toggle) with three options — All, Blocklist, Selfblock — to the dashboard toolbar and the map toolbar. The selection must propagate to the backend so that only matching bans are returned and pagination / country aggregation remain correct.
Implementation Details
Backend — add origin query parameter
- Dashboard router —
GET /api/dashboard/bansinbackend/app/routers/dashboard.py: add an optional query parameterorigin: Optional[Literal["blocklist", "selfblock"]] = None. Pass it through to the service layer. - Map router —
GET /api/dashboard/bans/by-countryin the same router: add the sameoriginparameter. - Service layer — In
backend/app/services/ban_service.py:list_bans(): whenoriginis provided, append a WHERE clause on thejailcolumn (jail = 'blocklist-import'for blocklist,jail != 'blocklist-import'for selfblock).bans_by_country(): apply the same jail-based filter so that country aggregation only counts matching bans.
Frontend — filter controls
- Shared state — Create a small shared type
BanOriginFilter = "all" | "blocklist" | "selfblock"(e.g. infrontend/src/types/or inline). - DashboardPage — In
frontend/src/pages/DashboardPage.tsx, add a dropdown or segmented control next to the existing time-range toolbar. Store the selected value in component state. Pass it touseBanshook, which forwards it as theoriginquery parameter. - useBans hook — In
frontend/src/hooks/useBans.ts, accept an optionaloriginparameter and include it in the API call viafetchBans(). - API function — In
frontend/src/api/dashboard.ts, updatefetchBans()to accept and forward theoriginquery parameter. - MapPage — In
frontend/src/pages/MapPage.tsx, add the same dropdown. Pass the selected value touseMapDatahook. - useMapData hook — In
frontend/src/hooks/useMapData.ts, acceptoriginand forward it tofetchBansByCountry(). - Map API function — In
frontend/src/api/map.ts, updatefetchBansByCountry()to includeoriginin the query string.
Tests
- Extend
backend/tests/test_services/test_ban_service.py: insert bans under multiple jails, calllist_bans(origin="blocklist")and assert onlyblocklist-importjail bans are returned; repeat for"selfblock"andNone. - Extend
backend/tests/test_routers/test_dashboard.py: hitGET /api/dashboard/bans?origin=blocklistand verify the response only contains blocklist bans.
Files Touched
| Layer | File |
|---|---|
| Router | backend/app/routers/dashboard.py |
| Service | backend/app/services/ban_service.py |
| Frontend | frontend/src/pages/DashboardPage.tsx |
| Frontend | frontend/src/pages/MapPage.tsx |
| Frontend | frontend/src/hooks/useBans.ts |
| Frontend | frontend/src/hooks/useMapData.ts |
| Frontend | frontend/src/api/dashboard.ts |
| Frontend | frontend/src/api/map.ts |
| Tests | backend/tests/test_services/test_ban_service.py |
| Tests | backend/tests/test_routers/test_dashboard.py |
Task 3 — Performance Optimisation for 10 k+ IPs (Dashboard & World Map)
Problem
With a large number of banned IPs (> 10 000), both the dashboard and the world map take over 10 seconds to load and become unusable. The root cause is the geo-enrichment step: geo_service.py calls the external ip-api.com endpoint one IP at a time with a 5-second timeout, and the free tier is rate-limited to 45 requests/minute. On a cold cache with 10 k IPs, this would take hours.
Goal
Dashboard and world map must load within 2 seconds for 10 k banned IPs. Write a reproducible benchmark test to prove it.
Implementation Details
Backend — persistent geo cache
-
SQLite geo cache table — In
backend/app/db.py, add ageo_cachetable:CREATE TABLE IF NOT EXISTS geo_cache ( ip TEXT PRIMARY KEY, country_code TEXT, country_name TEXT, asn TEXT, org TEXT, cached_at TEXT NOT NULL );On startup the service loads from this table instead of starting with an empty dict. Lookups hit the in-memory dict first, then fall through to the DB, and only as a last resort call the external API. Successful API responses are written back to both caches.
-
Batch lookup via ip-api.com batch endpoint — The free
ip-api.comAPI supports a batch POST tohttp://ip-api.com/batchaccepting up to 100 IPs per request. Refactorgeo_service.pyto:- Collect all uncached IPs from the requested page.
- Send them in chunks of 100 to the batch endpoint.
- Parse the JSON array response and populate caches in one pass. This alone reduces 10 000 cold lookups from 10 000 sequential requests to ≈ 100 batch calls.
-
Pre-warm cache on import — In
backend/app/services/blocklist_service.py, after a successful blocklist import, fire a background task that batch-resolves all newly imported IPs. This way the dashboard never faces a cold cache for blocklist IPs.
Backend — limit the country-aggregation query
- In
ban_service.py,bans_by_country()currently loads up to 2 000 bans and enriches every one. Change it to:- Run the aggregation (GROUP BY
jail, count per jail) in SQL directly against the fail2ban DB. - Only enrich the distinct IPs, batch-resolved.
- Return the aggregated country → count map without fetching full ban rows.
- Run the aggregation (GROUP BY
Frontend — virtualised table
- The
BanTablecomponent currently renders all rows in the DOM. For 10 k+ rows (even paginated at 100), scrolling is fine, but if page sizes are increased or if pagination is removed, install a virtual-scrolling library (e.g.@tanstack/react-virtual) and render only visible rows. Alternatively, ensure page size stays capped at ≤ 500 (already enforced) and measure whether DOM performance is acceptable — only virtualise if needed.
Frontend — map debounce
- In
WorldMap.tsx, add a loading skeleton / spinner and debounce the data fetch. If the user switches time ranges rapidly, cancel in-flight requests to avoid piling up stale responses.
Performance test
- Write a pytest benchmark in
backend/tests/test_services/test_ban_service_perf.py:- Seed a temporary fail2ban SQLite with 10 000 synthetic bans (random IPs, mixed jails, spread over 365 days).
- Pre-populate the geo cache with matching entries so the test does not hit the network.
- Call
list_bans(range="365d", page=1, page_size=100)andbans_by_country(range="365d"). - Assert both return within 2 seconds wall-clock time.
- Add a manual/integration test script
backend/tests/scripts/seed_10k_bans.pythat inserts 10 000 bans into the real fail2ban dev DB and pre-caches their geo data so developers can visually test dashboard and map load times in the browser.
Files Touched
| Layer | File |
|---|---|
| DB schema | backend/app/db.py |
| Geo service | backend/app/services/geo_service.py |
| Ban service | backend/app/services/ban_service.py |
| Blocklist service | backend/app/services/blocklist_service.py |
| Frontend | frontend/src/components/BanTable.tsx |
| Frontend | frontend/src/components/WorldMap.tsx |
| Tests | backend/tests/test_services/test_ban_service_perf.py (new) |
| Scripts | backend/tests/scripts/seed_10k_bans.py (new) |
Task 4 — Fix Missing Country for Resolved IPs
Problem
Many IPs (e.g. 5.167.71.2, 5.167.71.3, 5.167.71.4) show no country in the dashboard and map. The current geo_service.py calls ip-api.com with a 5-second timeout and silently returns None fields on any error or timeout. Common causes:
- Rate limiting — the free tier allows 45 req/min; once exceeded, responses return
"status": "fail"and the service cachesNonevalues, permanently hiding the country. - Cache poisoning with empty entries — a failed lookup stores
GeoInfo(country_code=None, ...)in the in-memory cache. Until the cache is flushed (at 10 000 entries), that IP will always appear without a country. - External API unreachable — network issues or container DNS problems cause timeouts that are treated the same as "no data".
Goal
Every IP that has a valid geographic mapping should display its country. Failed lookups must be retried on subsequent requests rather than permanently cached as empty.
Implementation Details
Backend — do not cache failed lookups
- In
backend/app/services/geo_service.py, change the caching logic: only store a result in the in-memory cache (and the new persistentgeo_cachetable from Task 3) whencountry_code is not None. If the API returned a failure or the request timed out, do not cache the result so it will be retried on the next request.
Backend — negative-cache with short TTL
- To avoid hammering the API for the same failing IP on every request, introduce a separate negative cache — a dict mapping IP → timestamp of last failed attempt. Skip re-lookup if the last failure was less than 5 minutes ago. After 5 minutes the IP becomes eligible for retry.
Backend — fallback to a local GeoIP database
- Add
geoip2(MaxMind GeoLite2) as an optional fallback. If the freeip-api.comlookup fails or is rate-limited, attempt a local lookup using the GeoLite2-Country database (.mmdbfile). This provides offline country resolution for the vast majority of IPv4 addresses.- Add
geoip2tobackend/pyproject.tomldependencies. - Download the GeoLite2-Country database during Docker build or via a setup script (requires free MaxMind license key).
- In
geo_service.py, tryip-api.comfirst; on failure, fall back to geoip2; only returnNoneif both fail.
- Add
Backend — bulk re-resolve endpoint
- Add
POST /api/geo/re-resolveinbackend/app/routers/geo.pythat:- Queries all currently cached IPs with
country_code IS NULL. - Re-resolves them in batches (using the batch endpoint from Task 3).
- Returns a count of newly resolved IPs.
- This lets the admin manually fix historical gaps.
- Queries all currently cached IPs with
Frontend — visual indicator for unknown country
- In
BanTable.tsxand the MapPage detail table, display a small "unknown" icon or—placeholder when country is null, instead of leaving the cell empty. Tooltip: "Country could not be resolved — will retry automatically."
Tests
- In
backend/tests/test_services/test_geo_service.py:- Test that a failed lookup is not persisted in the positive cache.
- Test that a failed lookup is stored in the negative cache and skipped for 5 minutes.
- Test that after the negative-cache TTL expires, the IP is re-queried.
- Mock the geoip2 fallback and verify it is called when ip-api fails.
- In
backend/tests/test_routers/test_geo.py, test the newPOST /api/geo/re-resolveendpoint.
Files Touched
| Layer | File |
|---|---|
| Geo service | backend/app/services/geo_service.py |
| Geo router | backend/app/routers/geo.py |
| Dependencies | backend/pyproject.toml |
| Frontend | frontend/src/components/BanTable.tsx |
| Frontend | frontend/src/pages/MapPage.tsx |
| Tests | backend/tests/test_services/test_geo_service.py |
| Tests | backend/tests/test_routers/test_geo.py |