10 Commits

Author SHA1 Message Date
c03a5c1cbc backup 2026-04-05 21:46:25 +02:00
eb983799cd chore: release v0.9.18 2026-04-05 21:45:28 +02:00
d3f564d66f Remove inline map count labels and hide archive source badges 2026-04-05 20:59:28 +02:00
bbd57c808b chore: release v0.9.17 2026-04-05 20:44:43 +02:00
ffaa14f864 Switch dashboard/map/history views to archive source for long-term data
Update fail2ban dbpurgeage to 648000 and history sync backfill/pagination for archive-based 7.5 day history.
2026-04-05 20:21:54 +02:00
7d09b78437 chore: release v0.9.16 2026-04-05 18:54:02 +02:00
8e2bb5d3fb Migrate WorldMap to d3-geo, fix TopoJSON country ID mappings and update tests 2026-04-05 18:50:44 +02:00
bfe0daf754 Fix WorldMap hover highlight by memoizing style objects and handlers
Memoize per-Geography style objects with useMemo so React.memo can
skip re-renders when only the tooltip position changes. Stabilize
mouse event handlers with useCallback using data-* attributes instead
of per-Geography closures. This eliminates the state-update race
condition that caused hover fill colors to flash back to defaults.
2026-04-01 14:53:38 +02:00
13823b1182 fix(history): unify History filter bar with Jail and IP inputs 2026-04-01 09:37:38 +02:00
7967191ccd backup 2026-03-29 21:24:12 +02:00
33 changed files with 699 additions and 544 deletions

View File

@@ -1 +1 @@
v0.9.15 v0.9.18

View File

@@ -78,6 +78,11 @@ Chains steps 13 automatically with appropriate sleep intervals.
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log` Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
(see `fail2ban/paths-lsio.conf``remote_logs_path = /remotelogs`). (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`: To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
```ini ```ini

View File

@@ -52,6 +52,8 @@ The main landing page after login. Shows recent ban activity at a glance.
- Last 7 days (week) - Last 7 days (week)
- Last 30 days (month) - Last 30 days (month)
- Last 365 days (year) - 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,16 @@ A geographical overview of ban activity.
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend) - 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 - 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. - **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. - 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 number and no label — they remain blank and transparent. - 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. - Clicking a country filters the companion table below to show only bans from that country.
- Time-range selector with the same quick presets: - Time-range selector with the same quick presets:
- Last 24 hours - Last 24 hours
- Last 7 days - Last 7 days
- Last 30 days - Last 30 days
- Last 365 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)**.
--- ---
@@ -245,13 +249,15 @@ A page to inspect and modify the fail2ban configuration without leaving the web
## 7. Ban History ## 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 ### History Table
- Browse all past bans across all jails, not just the currently active ones. - 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. - **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. - 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). - See at a glance which IPs are repeat offenders (high ban count).
### Per-IP History ### Per-IP History
@@ -265,7 +271,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. - 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. - 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. - 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. - 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. - 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. - On fail2ban connectivity failure, BanGUI continues serving historical data; next successful sync resumes ingestion without data loss.

View File

@@ -8,56 +8,71 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
## Open Issues ## Open Issues
### History page filters and behavior (frontend) ### Backend Architecture
1. [x] Time range filter currently not working - **Replace the single shared SQLite connection.**
- Scope: frontend `history` page state/logic (likely in `frontend/src` route or component for history table) - Current startup code opens one `aiosqlite.Connection` and reuses it for every request.
- Expected behavior: selecting start/end timestamps (or presets) should filter history rows by event timestamp in the backend query or local data, then rerender filtered results immediately. - This should be replaced with either a connection pool or request-scoped connections to avoid concurrency and locking issues.
- Investigation: - Update request dependencies, application lifecycle, and tests to use the new pattern.
- check component(s) handling time range input, change handlers, and query construction.
- confirm backend API supports `startTime`/`endTime` parameters for history endpoint (`/api/history` or similar).
- confirm values are passed as ISO strings/epoch and matched in backend.
- Fix should include:
- wiring the time range inputs to the filtering state.
- constructing API query with time range params.
- verifying with unit/integration tests plus manual through UI.
2. [x] Global filter (All | Blocklist | Selfblock) not working - **Refactor dependency wiring and shared resource management.**
- Scope: frontend `history` page filter chips or select control. - Remove hidden module-level import coupling between routers, services, and shared utilities.
- Expected behavior: choosing `All`, `Blocklist`, `Selfblock` should apply corresponding filter in same history query (no results for unmatched types). - Introduce explicit factories or providers for shared resources such as DB, HTTP client session, scheduler, and settings.
- Tasks: - Ensure routers depend on injected providers rather than global state or dynamic imports.
- locate filter control component and event handlers.
- validate value mapping semantics (`all`=>no filter, `blocklist`=>source=blocklist, `selfblock`=>source=selfblock or equivalent).
- ensure filter value is merged with time range + jail/ip filters in API call.
- add tests for each filter option to confirm behavior.
3. [x] Jail and IP address filters UX alignment with time range - **Harden fail2ban integration.**
- Scope: frontend `history` page layout/CSS component; likely `HistoryFilters` container. - Remove the `sys.path` hack that locates `fail2ban-master` at runtime.
- Expected behavior: - Replace it with a deterministic packaging or configuration model so the backend does not depend on repository layout.
- Jail filter and IP address filter should be same look (input/select style and spacing) as time range widget. - Refactor `Fail2BanClient` so concurrency control is instance-based and not backed by hidden module globals.
- Place Jail/IP filters next to time range controls in same row.
- Should be responsive and consistent.
- Tasks:
- modify the `history` filter container markup so time range, jail, ip filter are co-located.
- apply matching CSS classes/styles to Jail/IP as existing time range control.
- verify cross-browser/responsive display with storybook/test if exists.
4. [x] Remove “Apply” and “Clear” buttons; auto-apply on field change - **Improve startup / setup guard behavior.**
- Scope: frontend `history` filter form behavior. - Convert `SetupRedirectMiddleware` from an on-demand DB check into a startup/initialisation guard where possible.
- Expected behavior: - Cache setup completion in a safe way and provide an explicit invalidation path if the application state changes.
- Any filter field change (time range, type, jail, IP) triggers immediate query update (debounced 150-300 ms if needed). - Reduce middleware responsibility and avoid DB access during normal request dispatch.
- Remove explicit “Apply” and “Clear” buttons from UI.
- Clear can be replaced by resetting fields automatically or via a small “reset” icon if needed.
- Implementation steps:
- remove button UI elements and event bindings from history page component.
- make each filter input onChange call the shared `applyFilters` logic with current state.
- add debounce to avoid 100% rapid API churn.
- for clear semantics, ensure default/empty state on filter input binds to default query (All).
- add tests to verify no apply/clear buttons present and updates via input change.
### Acceptance criteria - **Make deployment configuration explicit.**
- On `history` page, time range selection + filter chips + jail/ip are functional and produce filtered results. - Move hard-coded environment assumptions such as CORS origins into settings.
- Time range, jail/IP inputs are styled consistently and in same filter row. - Ensure `fail2ban_socket`, `fail2ban_config_dir`, and startup commands are fully configurable via `Settings`.
- No apply/clear buttons are visible and filter updates occur on value change (with optional debounce). - Document production-ready defaults separately from development defaults.
- Relevant tests added/updated in frontend test suite.
### Reliability and Resilience
- **Add backend lifecycle tests for resource cleanup.**
- Verify startup opens and initialises DB, HTTP session, scheduler, and geo cache correctly.
- Verify shutdown closes those resources cleanly.
- **Add concurrency/regression coverage for DB and fail2ban socket use.**
- Add tests that simulate multiple concurrent requests using the same DB dependency.
- Add tests around fail2ban socket retries, protocol errors, and rate limiting.
- **Improve state caching and invalidation.**
- Add tests for session cache invalidation on logout.
- Add tests for setup completion caching so stale state is never served.
### Backend Feature Work
- **Document and implement backend-safe environment-driven CORS.**
- Add support for production and local development origins through configuration.
- Avoid a hardcoded Vite origin in the core app factory.
- **Centralise scheduler job registration.**
- Refactor APScheduler registration so background tasks are registered through a common lifecycle helper.
- Ensure jobs can be discovered, replaced, and tested without requiring implicit `app.state` side effects.
- **Strengthen fail2ban error handling and reporting.**
- Standardise `502` responses for connection/protocol failures across all endpoints.
- Add structured logging for retries and fatal socket failures.
- Ensure the UI can distinguish offline fail2ban from internal backend failures.
- **Improve documentation of backend responsibilities.**
- Keep `Docs/Tasks.md` aligned with the backend architecture review.
- Add references to the backend modules, resource lifecycle, and dependency model in the documentation.
### Priority Execution Plan
1. Fix the global SQLite connection pattern and tests.
2. Refactor dependency injection / explicit shared resources.
3. Harden fail2ban client concurrency and packaging.
4. Convert setup guard to a safer startup-driven model.
5. Add deployment-safe configuration and production-ready CORS.
6. Add lifecycle and concurrency regression tests.

View File

@@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
@@ -83,7 +83,7 @@ async def get_dashboard_bans(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), 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: 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."), page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
origin: BanOrigin | None = Query( origin: BanOrigin | None = Query(
@@ -137,7 +137,7 @@ async def get_bans_by_country(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), 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( origin: BanOrigin | None = Query(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -185,7 +185,7 @@ async def get_ban_trend(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), 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( origin: BanOrigin | None = Query(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -235,7 +235,7 @@ async def get_bans_by_jail(
request: Request, request: Request,
_auth: AuthDep, _auth: AuthDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), 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( origin: BanOrigin | None = Query(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",

View File

@@ -15,7 +15,7 @@ Routes
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
@@ -56,7 +56,7 @@ async def get_history(
default=None, default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
), ),
source: str = Query( source: Literal["fail2ban", "archive"] = Query(
default="fail2ban", default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.", description="Data source: 'fail2ban' or 'archive'.",
), ),

View File

@@ -26,7 +26,7 @@ JOB_ID: str = "history_sync"
HISTORY_SYNC_INTERVAL: int = 300 HISTORY_SYNC_INTERVAL: int = 300
#: Backfill window when archive is empty (seconds). #: 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: 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) log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW)
per_page = 500 per_page = 500
next_since = last_ts next_since = last_ts + 1
total_synced = 0 total_synced = 0
while True: while True:

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "bangui-backend" name = "bangui-backend"
version = "0.9.14" version = "0.9.18"
description = "BanGUI backend — fail2ban web management interface" description = "BanGUI backend — fail2ban web management interface"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [

View File

@@ -428,6 +428,15 @@ class TestBansByCountry:
called_range = mock_fn.call_args[0][1] called_range = mock_fn.call_args[0][1]
assert called_range == "7d" 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( async def test_empty_window_returns_empty_response(
self, dashboard_client: AsyncClient self, dashboard_client: AsyncClient
) -> None: ) -> None:
@@ -722,6 +731,15 @@ class TestBanTrend:
) )
assert response.status_code == 422 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: async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
"""Empty bucket list is serialised correctly.""" """Empty bucket list is serialised correctly."""
from app.models.ban import BanTrendResponse from app.models.ban import BanTrendResponse
@@ -857,6 +875,15 @@ class TestBansByJail:
) )
assert response.status_code == 422 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: async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None:
"""Empty jails list is serialised correctly.""" """Empty jails list is serialised correctly."""
from app.models.ban import BansByJailResponse from app.models.ban import BansByJailResponse

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock from unittest.mock import AsyncMock, MagicMock, patch
from app.tasks import history_sync from app.tasks import history_sync
@@ -27,3 +27,33 @@ class TestHistorySyncTask:
called_args, called_kwargs = fake_scheduler.add_job.call_args called_args, called_kwargs = fake_scheduler.add_job.call_args
assert called_kwargs["id"] == history_sync.JOB_ID assert called_kwargs["id"] == history_sync.JOB_ID
assert called_kwargs["kwargs"]["app"] == app 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)

View File

@@ -72,7 +72,7 @@ dbfile = /var/lib/fail2ban/fail2ban.sqlite3
# Options: dbpurgeage # Options: dbpurgeage
# Notes.: Sets age at which bans should be purged from the database # Notes.: Sets age at which bans should be purged from the database
# Values: [ SECONDS ] Default: 86400 (24hours) # Values: [ SECONDS ] Default: 86400 (24hours)
dbpurgeage = 1d dbpurgeage = 648000
# Options: dbmaxmatches # Options: dbmaxmatches
# Notes.: Number of matches stored in database per ticket (resolvable via # Notes.: Number of matches stored in database per ticket (resolvable via

View File

@@ -1,30 +1,33 @@
{ {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.14", "version": "0.9.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.14", "version": "0.9.17",
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.55.0", "@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257", "@fluentui/react-icons": "^2.0.257",
"@types/react-simple-maps": "^3.0.6", "d3-geo": "^3.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.27.0", "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": { "devDependencies": {
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/d3-geo": "^3.1.0",
"@types/node": "^25.3.2", "@types/node": "^25.3.2",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/topojson-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^8.13.0", "@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0", "@typescript-eslint/parser": "^8.13.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
@@ -3565,23 +3568,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-geo": { "node_modules/@types/d3-geo": {
"version": "2.0.7", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==", "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/geojson": "*" "@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": { "node_modules/@types/d3-path": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
@@ -3597,12 +3592,6 @@
"@types/d3-time": "*" "@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": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -3624,16 +3613,6 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT" "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": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3652,6 +3631,7 @@
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
@@ -3696,16 +3676,25 @@
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
}, },
"node_modules/@types/react-simple-maps": { "node_modules/@types/topojson-client": {
"version": "3.0.6", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==", "integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/d3-geo": "^2",
"@types/d3-zoom": "^2",
"@types/geojson": "*", "@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": { "node_modules/@types/use-sync-external-store": {
@@ -4476,28 +4465,6 @@
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
"license": "BSD-3-Clause" "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": { "node_modules/d3-format": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -4508,12 +4475,15 @@
} }
}, },
"node_modules/d3-geo": { "node_modules/d3-geo": {
"version": "2.0.2", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "BSD-3-Clause", "license": "ISC",
"dependencies": { "dependencies": {
"d3-array": "^2.5.0" "d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
} }
}, },
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
@@ -4550,12 +4520,6 @@
"node": ">=12" "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": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -4592,41 +4556,6 @@
"node": ">=12" "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": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5745,16 +5674,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -5982,18 +5901,6 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6110,23 +6017,6 @@
"react-dom": ">=16.8" "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": { "node_modules/recharts": {
"version": "3.8.0", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
@@ -7516,6 +7406,12 @@
"node": ">=0.10.0" "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": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "bangui-frontend", "name": "bangui-frontend",
"private": true, "private": true,
"version": "0.9.15", "version": "0.9.18",
"description": "BanGUI frontend — fail2ban web management interface", "description": "BanGUI frontend — fail2ban web management interface",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -17,14 +17,17 @@
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.55.0", "@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257", "@fluentui/react-icons": "^2.0.257",
"@types/react-simple-maps": "^3.0.6", "d3-geo": "^3.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.27.0", "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" "recharts": "^3.8.0"
}, },
"devDependencies": { "devDependencies": {
"@types/d3-geo": "^3.1.0",
"@types/topojson-client": "^3.0.0",
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",

View File

@@ -42,6 +42,7 @@ export async function fetchBans(
page = 1, page = 1,
pageSize = 100, pageSize = 100,
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<DashboardBanListResponse> { ): Promise<DashboardBanListResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
range, range,
@@ -51,6 +52,9 @@ export async function fetchBans(
if (origin !== "all") { if (origin !== "all") {
params.set("origin", origin); params.set("origin", origin);
} }
if (source !== "fail2ban") {
params.set("source", source);
}
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`); return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
} }
@@ -66,11 +70,15 @@ export async function fetchBans(
export async function fetchBanTrend( export async function fetchBanTrend(
range: TimeRange, range: TimeRange,
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<BanTrendResponse> { ): Promise<BanTrendResponse> {
const params = new URLSearchParams({ range }); const params = new URLSearchParams({ range });
if (origin !== "all") { if (origin !== "all") {
params.set("origin", origin); params.set("origin", origin);
} }
if (source !== "fail2ban") {
params.set("source", source);
}
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`); return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
} }
@@ -86,10 +94,14 @@ export async function fetchBanTrend(
export async function fetchBansByJail( export async function fetchBansByJail(
range: TimeRange, range: TimeRange,
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<BansByJailResponse> { ): Promise<BansByJailResponse> {
const params = new URLSearchParams({ range }); const params = new URLSearchParams({ range });
if (origin !== "all") { if (origin !== "all") {
params.set("origin", origin); params.set("origin", origin);
} }
if (source !== "fail2ban") {
params.set("source", source);
}
return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`); return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`);
} }

View File

@@ -21,6 +21,7 @@ export async function fetchHistory(
if (query.origin) params.set("origin", query.origin); if (query.origin) params.set("origin", query.origin);
if (query.jail) params.set("jail", query.jail); if (query.jail) params.set("jail", query.jail);
if (query.ip) params.set("ip", query.ip); 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 !== undefined) params.set("page", String(query.page));
if (query.page_size !== undefined) if (query.page_size !== undefined)
params.set("page_size", String(query.page_size)); params.set("page_size", String(query.page_size));

View File

@@ -17,10 +17,14 @@ import type { BanOriginFilter } from "../types/ban";
export async function fetchBansByCountry( export async function fetchBansByCountry(
range: TimeRange = "24h", range: TimeRange = "24h",
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<BansByCountryResponse> { ): Promise<BansByCountryResponse> {
const params = new URLSearchParams({ range }); const params = new URLSearchParams({ range });
if (origin !== "all") { if (origin !== "all") {
params.set("origin", origin); params.set("origin", origin);
} }
if (source !== "fail2ban") {
params.set("source", source);
}
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`); return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
} }

View File

@@ -46,6 +46,10 @@ interface BanTableProps {
* Changing this value triggers a re-fetch and resets to page 1. * Changing this value triggers a re-fetch and resets to page 1.
*/ */
origin?: BanOriginFilter; origin?: BanOriginFilter;
/**
* Data source used for the table query.
*/
source?: "fail2ban" | "archive";
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -186,9 +190,9 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
* @param props.timeRange - Active time-range preset from the parent page. * @param props.timeRange - Active time-range preset from the parent page.
* @param props.origin - Active origin filter 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 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); const banColumns = buildBanColumns(styles);

View File

@@ -53,6 +53,8 @@ interface BanTrendChartProps {
timeRange: TimeRange; timeRange: TimeRange;
/** Origin filter controlling which bans are included. */ /** Origin filter controlling which bans are included. */
origin: BanOriginFilter; origin: BanOriginFilter;
/** Data source used for the chart. */
source?: "fail2ban" | "archive";
} }
/** Internal chart data point shape. */ /** Internal chart data point shape. */
@@ -188,9 +190,10 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
export function BanTrendChart({ export function BanTrendChart({
timeRange, timeRange,
origin, origin,
source = "fail2ban",
}: BanTrendChartProps): React.JSX.Element { }: BanTrendChartProps): React.JSX.Element {
const styles = useStyles(); 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 isEmpty = buckets.every((b) => b.count === 0);
const entries = buildEntries(buckets, timeRange); const entries = buildEntries(buckets, timeRange);

View File

@@ -8,6 +8,7 @@
import { import {
Divider, Divider,
Input,
Text, Text,
ToggleButton, ToggleButton,
Toolbar, Toolbar,
@@ -35,6 +36,14 @@ export interface DashboardFilterBarProps {
originFilter: BanOriginFilter; originFilter: BanOriginFilter;
/** Called when the user selects a different origin filter. */ /** Called when the user selects a different origin filter. */
onOriginFilterChange: (value: BanOriginFilter) => void; onOriginFilterChange: (value: BanOriginFilter) => void;
/** Jail filter value (optional). */
jail?: string;
/** Called when the jail filter text changes (optional). */
onJailChange?: (value: string) => void;
/** IP address filter value (optional). */
ip?: string;
/** Called when the IP address filter text changes (optional). */
onIpChange?: (value: string) => void;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -92,6 +101,10 @@ export function DashboardFilterBar({
onTimeRangeChange, onTimeRangeChange,
originFilter, originFilter,
onOriginFilterChange, onOriginFilterChange,
jail,
onJailChange,
ip,
onIpChange,
}: DashboardFilterBarProps): React.JSX.Element { }: DashboardFilterBarProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles(); const cardStyles = useCardStyles();
@@ -146,6 +159,48 @@ export function DashboardFilterBar({
))} ))}
</Toolbar> </Toolbar>
</div> </div>
{onJailChange && (
<>
<div className={styles.divider}>
<Divider vertical />
</div>
<div className={styles.group}>
<Text weight="semibold" size={300}>
Jail
</Text>
<Input
placeholder="e.g. sshd"
size="small"
value={jail ?? ""}
onChange={(_ev, data): void => {
onJailChange(data.value);
}}
/>
</div>
</>
)}
{onIpChange && (
<>
<div className={styles.divider}>
<Divider vertical />
</div>
<div className={styles.group}>
<Text weight="semibold" size={300}>
IP Address
</Text>
<Input
placeholder="e.g. 192.168"
size="small"
value={ip ?? ""}
onChange={(_ev, data): void => {
onIpChange(data.value);
}}
/>
</div>
</>
)}
</div> </div>
); );
} }

View File

@@ -1,31 +1,42 @@
/** /**
* WorldMap — SVG world map showing per-country ban counts. * WorldMap — SVG world map showing per-country ban counts.
* *
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from * Uses a local TopoJSON bundle and d3-geo for projection, path generation,
* jsDelivr CDN. For each country that has bans in the selected time window, * and native SVG pan/zoom behaviour.
* the total count is displayed inside the country's borders. Clicking a
* country filters the companion table.
*/ */
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useCallback, useState } from "react"; import {
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button, makeStyles, tokens } from "@fluentui/react-components"; 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 { useCardStyles } from "../theme/commonStyles";
import type { GeoPermissibleObjects } from "d3-geo";
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
import { getBanCountColor } from "../utils/mapColors"; import { getBanCountColor } from "../utils/mapColors";
// --------------------------------------------------------------------------- const MAP_WIDTH = 800;
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only) const MAP_HEIGHT = 400;
// --------------------------------------------------------------------------- const MIN_ZOOM = 1;
const MAX_ZOOM = 8;
const GEO_URL = const ZOOM_STEP = 0.5;
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; const PAN_THRESHOLD = 3;
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({ const useStyles = makeStyles({
mapWrapper: { mapWrapper: {
@@ -33,6 +44,25 @@ const useStyles = makeStyles({
position: "relative", position: "relative",
overflow: "hidden", 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: { countLabel: {
fontSize: "9px", fontSize: "9px",
fontWeight: "600", fontWeight: "600",
@@ -73,194 +103,21 @@ const useStyles = makeStyles({
}, },
}); });
// --------------------------------------------------------------------------- type TopoJsonTopology = Topology & {
// GeoLayer — must be rendered inside ComposableMap to access map context objects: {
// --------------------------------------------------------------------------- countries: TopoGeometryCollection;
};
};
interface GeoLayerProps { type TooltipState = {
countries: Record<string, number>;
countryNames?: Record<string, string>;
selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void;
thresholdLow: number;
thresholdMedium: number;
thresholdHigh: number;
}
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; cc: string;
count: number; count: number;
name: string; name: string;
x: number; x: number;
y: number; y: number;
} } | null;
| null
>(null);
const handleClick = useCallback( interface WorldMapProps {
(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 (
<g key={geo.rsmKey} style={{ cursor: cc ? "pointer" : "default" }}>
<Geography
geography={geo}
role={cc ? "button" : undefined}
tabIndex={cc ? 0 : undefined}
aria-label={
cc
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
isSelected ? " (selected)" : ""
}`
: undefined
}
aria-pressed={isSelected || undefined}
onClick={(): void => {
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) && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
className={styles.countLabel}
>
{count}
</text>
)}
</g>
);
},
)}
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
</>
);
}
// ---------------------------------------------------------------------------
// WorldMap — public component
// ---------------------------------------------------------------------------
export interface WorldMapProps {
/** ISO alpha-2 country code → ban count. */ /** ISO alpha-2 country code → ban count. */
countries: Record<string, number>; countries: Record<string, number>;
/** Optional mapping from country code to display name. */ /** Optional mapping from country code to display name. */
@@ -288,21 +145,143 @@ export function WorldMap({
}: WorldMapProps): React.JSX.Element { }: WorldMapProps): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles(); const cardStyles = useCardStyles();
const [zoom, setZoom] = useState<number>(1); const [zoom, setZoom] = useState<number>(MIN_ZOOM);
const [center, setCenter] = useState<[number, number]>([0, 0]); const [center, setCenter] = useState<[number, number]>([0, 0]);
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
const [tooltip, setTooltip] = useState<TooltipState>(null);
const handleZoomIn = (): void => { const zoomRef = useRef<number>(zoom);
setZoom((z) => Math.min(z + 0.5, 8)); 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<boolean>(false);
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
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<unknown, Feature<Geometry, GeoJsonProperties>>>(
() => 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<SVGSVGElement>) => {
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 handleZoomOut = (): void => { const handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
setZoom((z) => Math.max(z - 0.5, 1)); const drag = dragStateRef.current;
}; if (!drag?.active) return;
const handleResetView = (): void => { const dx = event.clientX - drag.startX;
setZoom(1); 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<SVGSVGElement>) => {
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<SVGSVGElement>) => {
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]); setCenter([0, 0]);
}; }, []);
return ( return (
<div <div
@@ -310,13 +289,12 @@ export function WorldMap({
role="img" role="img"
aria-label="World map showing banned IP counts by country. Click a country to filter the table below." aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
> >
{/* Zoom controls */}
<div className={styles.zoomControls}> <div className={styles.zoomControls}>
<Button <Button
appearance="secondary" appearance="secondary"
size="small" size="small"
onClick={handleZoomIn} onClick={handleZoomIn}
disabled={zoom >= 8} disabled={zoom >= MAX_ZOOM}
title="Zoom in" title="Zoom in"
aria-label="Zoom in" aria-label="Zoom in"
> >
@@ -326,7 +304,7 @@ export function WorldMap({
appearance="secondary" appearance="secondary"
size="small" size="small"
onClick={handleZoomOut} onClick={handleZoomOut}
disabled={zoom <= 1} disabled={zoom <= MIN_ZOOM}
title="Zoom out" title="Zoom out"
aria-label="Zoom out" aria-label="Zoom out"
> >
@@ -336,7 +314,7 @@ export function WorldMap({
appearance="secondary" appearance="secondary"
size="small" size="small"
onClick={handleResetView} onClick={handleResetView}
disabled={zoom === 1 && center[0] === 0 && center[1] === 0} disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
title="Reset view" title="Reset view"
aria-label="Reset view" aria-label="Reset view"
> >
@@ -344,34 +322,111 @@ export function WorldMap({
</Button> </Button>
</div> </div>
<ComposableMap <svg
projection="geoMercator" className={styles.svg}
projectionConfig={{ scale: 130, center: [10, 20] }} viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
width={800} role="img"
height={400} aria-label="World map showing banned IP counts by country."
style={{ width: "100%", height: "auto" }} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
> >
<ZoomableGroup <g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
zoom={zoom} {countryFeatures.map((featureItem) => {
center={center} const rawId = featureItem.id;
onMoveEnd={({ zoom: newZoom, coordinates }): void => { const numericId = String(Number(rawId));
setZoom(newZoom); const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
setCenter(coordinates); 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 (
<g key={String(rawId)}>
<path
d={pathString}
role={cc ? "button" : undefined}
tabIndex={cc ? 0 : undefined}
aria-label={
cc
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
isSelected ? " (selected)" : ""
}`
: undefined
}
aria-pressed={isSelected || undefined}
className={`${styles.country} ${
isSelected ? styles.countrySelected : ""
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
style={
{
["--country-fill" as string]: fillColor,
["--country-hover-fill" as string]: isSelected
? tokens.colorBrandBackgroundHover
: tokens.colorBrandBackground2,
["--country-selected-fill" as string]: tokens.colorBrandBackground,
} as React.CSSProperties
}
onClick={(): void => {
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);
}} }}
minZoom={1}
maxZoom={8}
>
<GeoLayer
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/> />
</ZoomableGroup> </g>
</ComposableMap> );
})}
</g>
</svg>
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
</div> </div>
); );
} }

View File

@@ -125,4 +125,47 @@ describe("DashboardFilterBar", () => {
expect(onTimeRangeChange).toHaveBeenCalledOnce(); expect(onTimeRangeChange).toHaveBeenCalledOnce();
expect(onTimeRangeChange).toHaveBeenCalledWith("24h"); expect(onTimeRangeChange).toHaveBeenCalledWith("24h");
}); });
it("renders jail and ip input controls when provided", async () => {
const onJailChange = vi.fn();
const onIpChange = vi.fn();
render(
<FluentProvider theme={webLightTheme}>
<DashboardFilterBar
timeRange="24h"
onTimeRangeChange={vi.fn()}
originFilter="all"
onOriginFilterChange={vi.fn()}
jail=""
onJailChange={onJailChange}
ip=""
onIpChange={onIpChange}
/>
</FluentProvider>,
);
expect(screen.getByText(/Jail/i)).toBeInTheDocument();
expect(screen.getByText(/IP Address/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/e.g. sshd/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/e.g. 192.168/i)).toBeInTheDocument();
const jailInput = screen.getByPlaceholderText(/e.g. sshd/i);
const ipInput = screen.getByPlaceholderText(/e.g. 192.168/i);
const user = userEvent.setup();
await user.clear(jailInput);
await user.type(jailInput, "x");
expect(onJailChange).toHaveBeenLastCalledWith("x");
await user.clear(ipInput);
await user.type(ipInput, "1");
expect(onIpChange).toHaveBeenLastCalledWith("1");
});
it("does not render jail or ip inputs when handlers are missing", () => {
renderBar();
expect(screen.queryByText(/Jail/i)).toBeNull();
expect(screen.queryByText(/IP Address/i)).toBeNull();
});
}); });

View File

@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { FluentProvider, webLightTheme } from "@fluentui/react-components";
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry. vi.mock(
vi.mock("react-simple-maps", () => ({ "world-atlas/countries-110m.json",
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, () => ({
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, default: {
Geography: ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => <g {...props}>{children}</g>, type: "Topology",
useGeographies: () => ({ objects: {
geographies: [{ rsmKey: "geo-1", id: 840 }], countries: {
path: { centroid: () => [10, 10] }, 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"; import { WorldMap } from "../WorldMap";
@@ -34,19 +49,20 @@ describe("WorldMap", () => {
</FluentProvider>, </FluentProvider>,
); );
// Tooltip should not be present initially
expect(screen.queryByRole("tooltip")).toBeNull(); 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" }); const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
expect(countryButton).toBeInTheDocument(); 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 }); fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
const tooltip = screen.getByRole("tooltip"); const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("United States"); expect(tooltip).toHaveTextContent("United States");
expect(tooltip).toHaveTextContent("42 bans"); expect(tooltip).toHaveTextContent("42 bans");
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
fireEvent.mouseLeave(countryButton); fireEvent.mouseLeave(countryButton);
expect(screen.queryByRole("tooltip")).toBeNull(); expect(screen.queryByRole("tooltip")).toBeNull();

View File

@@ -8,6 +8,7 @@
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = { export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"4": "AF", "4": "AF",
"8": "AL", "8": "AL",
"10": "AQ",
"12": "DZ", "12": "DZ",
"16": "AS", "16": "AS",
"20": "AD", "20": "AD",
@@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"148": "TD", "148": "TD",
"152": "CL", "152": "CL",
"156": "CN", "156": "CN",
"158": "TW",
"162": "CX", "162": "CX",
"166": "CC", "166": "CC",
"170": "CO", "170": "CO",
@@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"250": "FR", "250": "FR",
"254": "GF", "254": "GF",
"258": "PF", "258": "PF",
"260": "TF",
"262": "DJ", "262": "DJ",
"266": "GA", "266": "GA",
"268": "GE", "268": "GE",
@@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
"372": "IE", "372": "IE",
"376": "IL", "376": "IL",
"380": "IT", "380": "IT",
"384": "CI",
"388": "JM", "388": "JM",
"392": "JP", "392": "JP",
"398": "KZ", "398": "KZ",

View File

@@ -42,6 +42,7 @@ export interface UseBanTrendResult {
export function useBanTrend( export function useBanTrend(
timeRange: TimeRange, timeRange: TimeRange,
origin: BanOriginFilter, origin: BanOriginFilter,
source: "fail2ban" | "archive" = "fail2ban",
): UseBanTrendResult { ): UseBanTrendResult {
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]); const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
const [bucketSize, setBucketSize] = useState<string>("1h"); const [bucketSize, setBucketSize] = useState<string>("1h");
@@ -58,7 +59,7 @@ export function useBanTrend(
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
fetchBanTrend(timeRange, origin) fetchBanTrend(timeRange, origin, source)
.then((data) => { .then((data) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setBuckets(data.buckets); setBuckets(data.buckets);
@@ -73,7 +74,7 @@ export function useBanTrend(
setIsLoading(false); setIsLoading(false);
} }
}); });
}, [timeRange, origin]); }, [timeRange, origin, source]);
useEffect(() => { useEffect(() => {
load(); load();

View File

@@ -44,6 +44,7 @@ export interface UseBansResult {
export function useBans( export function useBans(
timeRange: TimeRange, timeRange: TimeRange,
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): UseBansResult { ): UseBansResult {
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]); const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
const [total, setTotal] = useState<number>(0); const [total, setTotal] = useState<number>(0);
@@ -51,16 +52,16 @@ export function useBans(
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Reset page when time range or origin filter changes. // Reset page when time range, origin filter, or source changes.
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [timeRange, origin]); }, [timeRange, origin, source]);
const doFetch = useCallback(async (): Promise<void> => { const doFetch = useCallback(async (): Promise<void> => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin); const data = await fetchBans(timeRange, page, PAGE_SIZE, origin, source);
setBanItems(data.items); setBanItems(data.items);
setTotal(data.total); setTotal(data.total);
} catch (err: unknown) { } catch (err: unknown) {
@@ -68,7 +69,7 @@ export function useBans(
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [timeRange, page, origin]); }, [timeRange, page, origin, source]);
// Stable ref to the latest doFetch so the refresh callback is always current. // Stable ref to the latest doFetch so the refresh callback is always current.
const doFetchRef = useRef(doFetch); const doFetchRef = useRef(doFetch);

View File

@@ -48,6 +48,7 @@ export interface UseDashboardCountryDataResult {
export function useDashboardCountryData( export function useDashboardCountryData(
timeRange: TimeRange, timeRange: TimeRange,
origin: BanOriginFilter, origin: BanOriginFilter,
source: "fail2ban" | "archive" = "fail2ban",
): UseDashboardCountryDataResult { ): UseDashboardCountryDataResult {
const [countries, setCountries] = useState<Record<string, number>>({}); const [countries, setCountries] = useState<Record<string, number>>({});
const [countryNames, setCountryNames] = useState<Record<string, string>>({}); const [countryNames, setCountryNames] = useState<Record<string, string>>({});
@@ -67,7 +68,7 @@ export function useDashboardCountryData(
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
fetchBansByCountry(timeRange, origin) fetchBansByCountry(timeRange, origin, source)
.then((data) => { .then((data) => {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setCountries(data.countries); setCountries(data.countries);
@@ -85,7 +86,7 @@ export function useDashboardCountryData(
setIsLoading(false); setIsLoading(false);
} }
}); });
}, [timeRange, origin]); }, [timeRange, origin, source]);
useEffect(() => { useEffect(() => {
load(); load();

View File

@@ -43,6 +43,7 @@ export interface UseMapDataResult {
export function useMapData( export function useMapData(
range: TimeRange = "24h", range: TimeRange = "24h",
origin: BanOriginFilter = "all", origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): UseMapDataResult { ): UseMapDataResult {
const [data, setData] = useState<BansByCountryResponse | null>(null); const [data, setData] = useState<BansByCountryResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -64,7 +65,7 @@ export function useMapData(
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = new AbortController(); abortRef.current = new AbortController();
fetchBansByCountry(range, origin) fetchBansByCountry(range, origin, source)
.then((resp) => { .then((resp) => {
setData(resp); setData(resp);
}) })
@@ -75,7 +76,7 @@ export function useMapData(
setLoading(false); setLoading(false);
}); });
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
}, [range, origin]); }, [range, origin, source]);
useEffect((): (() => void) => { useEffect((): (() => void) => {
load(); load();

View File

@@ -71,8 +71,10 @@ export function DashboardPage(): React.JSX.Element {
const [timeRange, setTimeRange] = useState<TimeRange>("24h"); const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all"); const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const source = timeRange === "24h" ? "fail2ban" : "archive";
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } = const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
useDashboardCountryData(timeRange, originFilter); useDashboardCountryData(timeRange, originFilter, source);
const sectionStyles = useCommonSectionStyles(); const sectionStyles = useCommonSectionStyles();
@@ -86,12 +88,14 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Global filter bar */} {/* Global filter bar */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
<DashboardFilterBar <DashboardFilterBar
timeRange={timeRange} timeRange={timeRange}
onTimeRangeChange={setTimeRange} onTimeRangeChange={setTimeRange}
originFilter={originFilter} originFilter={originFilter}
onOriginFilterChange={setOriginFilter} onOriginFilterChange={setOriginFilter}
/> />
</div>
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
{/* Ban Trend section */} {/* Ban Trend section */}
@@ -103,7 +107,7 @@ export function DashboardPage(): React.JSX.Element {
</Text> </Text>
</div> </div>
<div className={styles.tabContent}> <div className={styles.tabContent}>
<BanTrendChart timeRange={timeRange} origin={originFilter} /> <BanTrendChart timeRange={timeRange} origin={originFilter} source={source} />
</div> </div>
</div> </div>
@@ -154,7 +158,7 @@ export function DashboardPage(): React.JSX.Element {
{/* Ban table */} {/* Ban table */}
<div className={styles.tabContent}> <div className={styles.tabContent}>
<BanTable timeRange={timeRange} origin={originFilter} /> <BanTable timeRange={timeRange} origin={originFilter} source={source} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -16,7 +16,6 @@ import {
DataGridHeader, DataGridHeader,
DataGridHeaderCell, DataGridHeaderCell,
DataGridRow, DataGridRow,
Input,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
Spinner, Spinner,
@@ -82,11 +81,6 @@ const useStyles = makeStyles({
gap: tokens.spacingHorizontalM, gap: tokens.spacingHorizontalM,
flexWrap: "wrap", flexWrap: "wrap",
}, },
filterLabel: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
tableWrapper: { tableWrapper: {
overflow: "auto", overflow: "auto",
borderRadius: tokens.borderRadiusMedium, borderRadius: tokens.borderRadiusMedium,
@@ -149,6 +143,7 @@ function areHistoryQueriesEqual(
a.origin === b.origin && a.origin === b.origin &&
a.jail === b.jail && a.jail === b.jail &&
a.ip === b.ip && a.ip === b.ip &&
a.source === b.source &&
a.page === b.page && a.page === b.page &&
a.page_size === b.page_size a.page_size === b.page_size
); );
@@ -390,14 +385,14 @@ function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
export function HistoryPage(): React.JSX.Element { export function HistoryPage(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const cardStyles = useCardStyles();
// Filter state // Filter state
const [range, setRange] = useState<TimeRange>("24h"); const [range, setRange] = useState<TimeRange>("7d");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all"); const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState(""); const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState(""); const [ipFilter, setIpFilter] = useState("");
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({ const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
source: "archive",
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}); });
@@ -413,6 +408,7 @@ export function HistoryPage(): React.JSX.Element {
origin: originFilter !== "all" ? originFilter : undefined, origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined, jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined, ip: ipFilter.trim() || undefined,
source: "archive",
page: 1, page: 1,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}; };
@@ -483,51 +479,17 @@ export function HistoryPage(): React.JSX.Element {
onOriginFilterChange={(value) => { onOriginFilterChange={(value) => {
setOriginFilter(value); setOriginFilter(value);
}} }}
/> jail={jailFilter}
onJailChange={(value) => {
<div className={`${styles.filterLabel} ${cardStyles.card}`}> setJailFilter(value);
<Text size={200}>Jail</Text>
<Input
placeholder="e.g. sshd"
value={jailFilter}
onChange={(_ev, data): void => {
setJailFilter(data.value);
}} }}
size="small" ip={ipFilter}
/> onIpChange={(value) => {
</div> setIpFilter(value);
<div className={`${styles.filterLabel} ${cardStyles.card}`}>
<Text size={200}>IP Address</Text>
<Input
placeholder="e.g. 192.168"
value={ipFilter}
onChange={(_ev, data): void => {
setIpFilter(data.value);
}} }}
size="small"
/> />
</div> </div>
</div>
{/* ---------------------------------------------------------------- */}
{/* Error / loading state */}
{/* ---------------------------------------------------------------- */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{loading && !error && (
<div
style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}
>
<Spinner label="Loading history…" />
</div>
)}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* Summary */} {/* Summary */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -98,8 +98,10 @@ export function MapPage(): React.JSX.Element {
const PAGE_SIZE_OPTIONS = [25, 50, 100] as const; const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
const source = range === "24h" ? "fail2ban" : "archive";
const { countries, countryNames, bans, total, loading, error, refresh } = const { countries, countryNames, bans, total, loading, error, refresh } =
useMapData(range, originFilter); useMapData(range, originFilter, source);
const { const {
thresholds: mapThresholds, thresholds: mapThresholds,

View File

@@ -50,7 +50,8 @@ describe("HistoryPage", () => {
// Initial load should include the auto-applied default query. // Initial load should include the auto-applied default query.
await waitFor(() => { await waitFor(() => {
expect(lastQuery).toEqual({ expect(lastQuery).toEqual({
range: "24h", range: "7d",
source: "archive",
origin: undefined, origin: undefined,
jail: undefined, jail: undefined,
ip: undefined, ip: undefined,

View File

@@ -57,6 +57,7 @@ export interface HistoryQuery {
origin?: BanOriginFilter; origin?: BanOriginFilter;
jail?: string; jail?: string;
ip?: string; ip?: string;
source?: "fail2ban" | "archive";
page?: number; page?: number;
page_size?: number; page_size?: number;
} }

View File

@@ -8,6 +8,8 @@
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",