25 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
470c29443c chore: release v0.9.15 2026-03-29 21:21:30 +02:00
6f15e1fa24 fix(map): add test-only useMapData exports for MapPage mock 2026-03-29 21:20:54 +02:00
487cb171f2 fix(history): auto-apply history filters and remove explicit buttons
HistoryPage no longer requires Apply/Clear; filter state auto-syncs with query. Added guard to avoid redundant state updates. Updated task list in Docs/Tasks.md to mark completion.
2026-03-29 20:35:11 +02:00
7789353690 Add MapPage pagination and page-size selector; update Web-Design docs 2026-03-29 15:23:47 +02:00
ccfcbc82c5 backup 2026-03-29 15:01:30 +02:00
7626c9cb60 Fix WorldMap hover tooltip/role behavior and mark task done 2026-03-29 15:01:10 +02:00
ac4fd967aa Fix update_jail_config to ignore backend field 2026-03-28 12:55:32 +01:00
9f05da2d4d Complete history archive support for dashboard/map data and mark task finished
Add source=archive option for dashboard endpoints and history service; update Docs/Tasks.md; include archive branch for list_bans, bans_by_country, ban_trend, bans_by_jail; tests for archive paths.
2026-03-28 12:39:47 +01:00
876af46955 history archive router precedence + endpoint/source tests + history sync register test + task status update 2026-03-24 21:06:58 +01:00
0d4a2a3311 history archive purge uses current age and test uses dynamic timestamps 2026-03-24 20:52:40 +01:00
f555b1b0a2 Add server dbpurgeage warning state in API and mark task complete 2026-03-24 20:45:07 +01:00
a30b92471a chore: persist docs and frontend lockfile updates 2026-03-24 20:20:35 +01:00
9e43282bbc fix(config): stabilize config hook callbacks to prevent action/filter flicker 2026-03-24 20:13:23 +01:00
2ea4a8304f backup 2026-03-24 19:46:12 +01:00
e99920e616 chore: release v0.9.14 2026-03-24 19:38:05 +01:00
57 changed files with 2130 additions and 951 deletions

View File

@@ -1 +1 @@
v0.9.13 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

@@ -68,6 +68,15 @@ FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}" sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
echo "frontend/package.json version updated → ${FRONT_VERSION}" echo "frontend/package.json version updated → ${FRONT_VERSION}"
# Keep backend/pyproject.toml in sync so app.__version__ matches Docker/VERSION in the runtime container.
BACKEND_PYPROJECT="${SCRIPT_DIR}/../backend/pyproject.toml"
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
echo "backend/pyproject.toml version updated → ${FRONT_VERSION}"
else
echo "Warning: backend/pyproject.toml not found, skipping backend version sync" >&2
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Push containers # Push containers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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
@@ -259,6 +265,17 @@ A view for exploring historical ban data stored in the fail2ban database.
- Select any IP to see its full ban timeline: every ban event, which jail triggered it, when it started, and how long it lasted. - Select any IP to see its full ban timeline: every ban event, which jail triggered it, when it started, and how long it lasted.
- Merged view showing total failures and matched log lines aggregated across all bans for that IP. - Merged view showing total failures and matched log lines aggregated across all bans for that IP.
### Persistent Historical Archive
- BanGUI stores a separate long-term historical ban archive in its own application database, independent from fail2ban's database retention settings.
- On each configured sync cycle (default every 5 minutes), BanGUI reads latest entries from fail2ban `bans` table and appends any new events to BanGUI history storage.
- Supports both `ban` and `unban` events; audit record includes: `timestamp`, `ip`, `jail`, `action`, `duration`, `origin` (manual, auto, blocklist, etc.), `failures`, `matches`, and optional `country` / `ASN` enrichment.
- Includes incremental import logic with dedupe: using unique constraint on (ip, jail, action, timeofban) to prevent duplication across sync cycles.
- Provides backfill mode for initial startup: import the last 7.5 days of existing fail2ban history into BanGUI to avoid dark gaps after restart. Requires fail2ban's `dbpurgeage` to be set to at least `648000` (7.5 days) — BanGUI ships with this value pre-configured in its Docker setup.
- Includes configurable archive purge policy in BanGUI (default 365 days), separate from fail2ban `dbpurgeage`, to keep app storage bounded while preserving audit data.
- Expose API endpoints for querying persistent history, with filters for timeframe, jail, origin, IP, and current ban status.
- On fail2ban connectivity failure, BanGUI continues serving historical data; next successful sync resumes ingestion without data loss.
--- ---
## 8. External Blocklist Importer ## 8. External Blocklist Importer

View File

@@ -7,3 +7,72 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
--- ---
## Open Issues ## Open Issues
### Backend Architecture
- **Replace the single shared SQLite connection.**
- Current startup code opens one `aiosqlite.Connection` and reuses it for every request.
- This should be replaced with either a connection pool or request-scoped connections to avoid concurrency and locking issues.
- Update request dependencies, application lifecycle, and tests to use the new pattern.
- **Refactor dependency wiring and shared resource management.**
- Remove hidden module-level import coupling between routers, services, and shared utilities.
- Introduce explicit factories or providers for shared resources such as DB, HTTP client session, scheduler, and settings.
- Ensure routers depend on injected providers rather than global state or dynamic imports.
- **Harden fail2ban integration.**
- Remove the `sys.path` hack that locates `fail2ban-master` at runtime.
- Replace it with a deterministic packaging or configuration model so the backend does not depend on repository layout.
- Refactor `Fail2BanClient` so concurrency control is instance-based and not backed by hidden module globals.
- **Improve startup / setup guard behavior.**
- Convert `SetupRedirectMiddleware` from an on-demand DB check into a startup/initialisation guard where possible.
- Cache setup completion in a safe way and provide an explicit invalidation path if the application state changes.
- Reduce middleware responsibility and avoid DB access during normal request dispatch.
- **Make deployment configuration explicit.**
- Move hard-coded environment assumptions such as CORS origins into settings.
- Ensure `fail2ban_socket`, `fail2ban_config_dir`, and startup commands are fully configurable via `Settings`.
- Document production-ready defaults separately from development defaults.
### 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

@@ -210,7 +210,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho
| Element | Fluent component | Notes | | Element | Fluent component | Notes |
|---|---|---| |---|---|---|
| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. | | Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. Use clear pagination controls (page number + prev/next) and a page-size selector (25/50/100) for large result sets. |
| Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. | | Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. |
| Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. | | Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. |
| Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. | | Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. |

View File

@@ -1,224 +0,0 @@
# Config File Service Extraction Summary
## ✓ Extraction Complete
Three new service modules have been created by extracting functions from `config_file_service.py`.
### Files Created
| File | Lines | Status |
|------|-------|--------|
| [jail_config_service.py](jail_config_service.py) | 991 | ✓ Created |
| [filter_config_service.py](filter_config_service.py) | 765 | ✓ Created |
| [action_config_service.py](action_config_service.py) | 988 | ✓ Created |
| **Total** | **2,744** | **✓ Verified** |
---
## 1. JAIL_CONFIG Service (`jail_config_service.py`)
### Public Functions (7)
- `list_inactive_jails(config_dir, socket_path)` → InactiveJailListResponse
- `activate_jail(config_dir, socket_path, name, req)` → JailActivationResponse
- `deactivate_jail(config_dir, socket_path, name)` → JailActivationResponse
- `delete_jail_local_override(config_dir, socket_path, name)` → None
- `validate_jail_config(config_dir, name)` → JailValidationResult
- `rollback_jail(config_dir, socket_path, name, start_cmd_parts)` → RollbackResponse
- `_rollback_activation_async(config_dir, name, socket_path, original_content)` → bool
### Helper Functions (5)
- `_write_local_override_sync()` - Atomic write of jail.d/{name}.local
- `_restore_local_file_sync()` - Restore or delete .local file during rollback
- `_validate_regex_patterns()` - Validate failregex/ignoreregex patterns
- `_set_jail_local_key_sync()` - Update single key in jail section
- `_validate_jail_config_sync()` - Synchronous validation (filter/action files, patterns, logpath)
### Custom Exceptions (3)
- `JailNotFoundInConfigError`
- `JailAlreadyActiveError`
- `JailAlreadyInactiveError`
### Shared Dependencies Imported
- `_safe_jail_name()` - From config_file_service
- `_parse_jails_sync()` - From config_file_service
- `_build_inactive_jail()` - From config_file_service
- `_get_active_jail_names()` - From config_file_service
- `_probe_fail2ban_running()` - From config_file_service
- `wait_for_fail2ban()` - From config_file_service
- `start_daemon()` - From config_file_service
- `_resolve_filter()` - From config_file_service
- `_parse_multiline()` - From config_file_service
- `_SOCKET_TIMEOUT`, `_META_SECTIONS` - Constants
---
## 2. FILTER_CONFIG Service (`filter_config_service.py`)
### Public Functions (6)
- `list_filters(config_dir, socket_path)` → FilterListResponse
- `get_filter(config_dir, socket_path, name)` → FilterConfig
- `update_filter(config_dir, socket_path, name, req, do_reload=False)` → FilterConfig
- `create_filter(config_dir, socket_path, req, do_reload=False)` → FilterConfig
- `delete_filter(config_dir, name)` → None
- `assign_filter_to_jail(config_dir, socket_path, jail_name, req, do_reload=False)` → None
### Helper Functions (4)
- `_extract_filter_base_name(filter_raw)` - Extract base name from filter string
- `_build_filter_to_jails_map()` - Map filters to jails using them
- `_parse_filters_sync()` - Scan filter.d/ and return tuples
- `_write_filter_local_sync()` - Atomic write of filter.d/{name}.local
- `_validate_regex_patterns()` - Validate regex patterns (shared with jail_config)
### Custom Exceptions (5)
- `FilterNotFoundError`
- `FilterAlreadyExistsError`
- `FilterReadonlyError`
- `FilterInvalidRegexError`
- `FilterNameError` (re-exported from config_file_service)
### Shared Dependencies Imported
- `_safe_filter_name()` - From config_file_service
- `_safe_jail_name()` - From config_file_service
- `_parse_jails_sync()` - From config_file_service
- `_get_active_jail_names()` - From config_file_service
- `_resolve_filter()` - From config_file_service
- `_parse_multiline()` - From config_file_service
- `_SAFE_FILTER_NAME_RE` - Constant pattern
---
## 3. ACTION_CONFIG Service (`action_config_service.py`)
### Public Functions (7)
- `list_actions(config_dir, socket_path)` → ActionListResponse
- `get_action(config_dir, socket_path, name)` → ActionConfig
- `update_action(config_dir, socket_path, name, req, do_reload=False)` → ActionConfig
- `create_action(config_dir, socket_path, req, do_reload=False)` → ActionConfig
- `delete_action(config_dir, name)` → None
- `assign_action_to_jail(config_dir, socket_path, jail_name, req, do_reload=False)` → None
- `remove_action_from_jail(config_dir, socket_path, jail_name, action_name, do_reload=False)` → None
### Helper Functions (5)
- `_safe_action_name(name)` - Validate action name
- `_extract_action_base_name()` - Extract base name from action string
- `_build_action_to_jails_map()` - Map actions to jails using them
- `_parse_actions_sync()` - Scan action.d/ and return tuples
- `_append_jail_action_sync()` - Append action to jail.d/{name}.local
- `_remove_jail_action_sync()` - Remove action from jail.d/{name}.local
- `_write_action_local_sync()` - Atomic write of action.d/{name}.local
### Custom Exceptions (4)
- `ActionNotFoundError`
- `ActionAlreadyExistsError`
- `ActionReadonlyError`
- `ActionNameError`
### Shared Dependencies Imported
- `_safe_jail_name()` - From config_file_service
- `_parse_jails_sync()` - From config_file_service
- `_get_active_jail_names()` - From config_file_service
- `_build_parser()` - From config_file_service
- `_SAFE_ACTION_NAME_RE` - Constant pattern
---
## 4. SHARED Utilities (remain in `config_file_service.py`)
### Utility Functions (14)
- `_safe_jail_name(name)` → str
- `_safe_filter_name(name)` → str
- `_ordered_config_files(config_dir)` → list[Path]
- `_build_parser()` → configparser.RawConfigParser
- `_is_truthy(value)` → bool
- `_parse_int_safe(value)` → int | None
- `_parse_time_to_seconds(value, default)` → int
- `_parse_multiline(raw)` → list[str]
- `_resolve_filter(raw_filter, jail_name, mode)` → str
- `_parse_jails_sync(config_dir)` → tuple
- `_build_inactive_jail(name, settings, source_file, config_dir=None)` → InactiveJail
- `_get_active_jail_names(socket_path)` → set[str]
- `_probe_fail2ban_running(socket_path)` → bool
- `wait_for_fail2ban(socket_path, max_wait_seconds, poll_interval)` → bool
- `start_daemon(start_cmd_parts)` → bool
### Shared Exceptions (3)
- `JailNameError`
- `FilterNameError`
- `ConfigWriteError`
### Constants (7)
- `_SOCKET_TIMEOUT`
- `_SAFE_JAIL_NAME_RE`
- `_META_SECTIONS`
- `_TRUE_VALUES`
- `_FALSE_VALUES`
---
## Import Dependencies
### jail_config_service imports:
```python
config_file_service: (shared utilities + private functions)
jail_service.reload_all()
Fail2BanConnectionError
```
### filter_config_service imports:
```python
config_file_service: (shared utilities + _set_jail_local_key_sync)
jail_service.reload_all()
conffile_parser: (parse/merge/serialize filter functions)
jail_config_service: (JailNotFoundInConfigError - lazy import)
```
### action_config_service imports:
```python
config_file_service: (shared utilities + _build_parser)
jail_service.reload_all()
conffile_parser: (parse/merge/serialize action functions)
jail_config_service: (JailNotFoundInConfigError - lazy import)
```
---
## Cross-Service Dependencies
**Circular imports handled via lazy imports:**
- `filter_config_service` imports `JailNotFoundInConfigError` from `jail_config_service` inside function
- `action_config_service` imports `JailNotFoundInConfigError` from `jail_config_service` inside function
**Shared functions re-used:**
- `_set_jail_local_key_sync()` exported from `jail_config_service`, used by `filter_config_service`
- `_append_jail_action_sync()` and `_remove_jail_action_sync()` internal to `action_config_service`
---
## Verification Results
**Syntax Check:** All three files compile without errors
**Import Verification:** All imports resolved correctly
**Total Lines:** 2,744 lines across three new files
**Function Coverage:** 100% of specified functions extracted
**Type Hints:** Preserved throughout
**Docstrings:** All preserved with full documentation
**Comments:** All inline comments preserved
---
## Next Steps (if needed)
1. **Update router imports** - Point from config_file_service to specific service modules:
- `jail_config_service` for jail operations
- `filter_config_service` for filter operations
- `action_config_service` for action operations
2. **Update config_file_service.py** - Remove all extracted functions (optional cleanup)
- Optionally keep it as a facade/aggregator
- Or reduce it to only the shared utilities module
3. **Add __all__ exports** to each new module for cleaner public API
4. **Update type hints** in models if needed for cross-service usage
5. **Testing** - Run existing tests to ensure no regressions

View File

@@ -75,6 +75,20 @@ CREATE TABLE IF NOT EXISTS geo_cache (
); );
""" """
_CREATE_HISTORY_ARCHIVE: str = """
CREATE TABLE IF NOT EXISTS history_archive (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jail TEXT NOT NULL,
ip TEXT NOT NULL,
timeofban INTEGER NOT NULL,
bancount INTEGER NOT NULL,
data TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('ban', 'unban')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(ip, jail, action, timeofban)
);
"""
# Ordered list of DDL statements to execute on initialisation. # Ordered list of DDL statements to execute on initialisation.
_SCHEMA_STATEMENTS: list[str] = [ _SCHEMA_STATEMENTS: list[str] = [
_CREATE_SETTINGS, _CREATE_SETTINGS,
@@ -83,6 +97,7 @@ _SCHEMA_STATEMENTS: list[str] = [
_CREATE_BLOCKLIST_SOURCES, _CREATE_BLOCKLIST_SOURCES,
_CREATE_IMPORT_LOG, _CREATE_IMPORT_LOG,
_CREATE_GEO_CACHE, _CREATE_GEO_CACHE,
_CREATE_HISTORY_ARCHIVE,
] ]

View File

@@ -48,7 +48,7 @@ from app.routers import (
server, server,
setup, setup,
) )
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check, history_sync
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
from app.utils.jail_config import ensure_jail_configs from app.utils.jail_config import ensure_jail_configs
@@ -183,6 +183,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# --- Periodic re-resolve of NULL-country geo entries --- # --- Periodic re-resolve of NULL-country geo entries ---
geo_re_resolve.register(app) geo_re_resolve.register(app)
# --- Periodic history sync from fail2ban into BanGUI archive ---
history_sync.register(app)
log.info("bangui_started") log.info("bangui_started")
try: try:

View File

@@ -56,3 +56,7 @@ class ServerSettingsResponse(BaseModel):
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
settings: ServerSettings settings: ServerSettings
warnings: dict[str, bool] = Field(
default_factory=dict,
description="Warnings highlighting potentially unsafe settings.",
)

View File

@@ -0,0 +1,148 @@
"""Ban history archive repository.
Provides persistence APIs for the BanGUI archival history table in the
application database.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from app.models.ban import BLOCKLIST_JAIL, BanOrigin
if TYPE_CHECKING:
import aiosqlite
async def archive_ban_event(
db: aiosqlite.Connection,
jail: str,
ip: str,
timeofban: int,
bancount: int,
data: str,
action: str = "ban",
) -> bool:
"""Insert a new archived ban/unban event, ignoring duplicates."""
async with db.execute(
"""INSERT OR IGNORE INTO history_archive
(jail, ip, timeofban, bancount, data, action)
VALUES (?, ?, ?, ?, ?, ?)""",
(jail, ip, timeofban, bancount, data, action),
) as cursor:
inserted = cursor.rowcount == 1
await db.commit()
return inserted
async def get_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[dict], int]:
"""Return a paginated archived history result set."""
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
offset = (page - 1) * page_size
async with db.execute(f"SELECT COUNT(*) FROM history_archive {where_sql}", params) as cur:
row = await cur.fetchone()
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, ip, timeofban, bancount, data, action "
"FROM history_archive "
f"{where_sql} "
"ORDER BY timeofban DESC LIMIT ? OFFSET ?",
[*params, page_size, offset],
) as cur:
rows = await cur.fetchall()
records = [
{
"jail": str(r[0]),
"ip": str(r[1]),
"timeofban": int(r[2]),
"bancount": int(r[3]),
"data": str(r[4]),
"action": str(r[5]),
}
for r in rows
]
return records, total
async def get_all_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict]:
"""Return all archived history rows for the given filters."""
page: int = 1
page_size: int = 500
all_rows: list[dict] = []
while True:
rows, total = await get_archived_history(
db=db,
since=since,
jail=jail,
ip_filter=ip_filter,
origin=origin,
action=action,
page=page,
page_size=page_size,
)
all_rows.extend(rows)
if len(rows) < page_size:
break
page += 1
return all_rows
async def purge_archived_history(db: aiosqlite.Connection, age_seconds: int) -> int:
"""Purge archived entries older than *age_seconds*; return rows deleted."""
threshold = int(datetime.datetime.now(datetime.UTC).timestamp()) - age_seconds
async with db.execute(
"DELETE FROM history_archive WHERE timeofban < ?",
(threshold,),
) as cursor:
deleted = cursor.rowcount
await db.commit()
return deleted

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,6 +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: 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(
@@ -117,10 +118,11 @@ async def get_dashboard_bans(
return await ban_service.list_bans( return await ban_service.list_bans(
socket_path, socket_path,
range, range,
source=source,
page=page, page=page,
page_size=page_size, page_size=page_size,
http_session=http_session, http_session=http_session,
app_db=None, app_db=request.app.state.db,
geo_batch_lookup=geo_service.lookup_batch, geo_batch_lookup=geo_service.lookup_batch,
origin=origin, origin=origin,
) )
@@ -135,6 +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: 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.",
@@ -164,10 +167,11 @@ async def get_bans_by_country(
return await ban_service.bans_by_country( return await ban_service.bans_by_country(
socket_path, socket_path,
range, range,
source=source,
http_session=http_session, http_session=http_session,
geo_cache_lookup=geo_service.lookup_cached_only, geo_cache_lookup=geo_service.lookup_cached_only,
geo_batch_lookup=geo_service.lookup_batch, geo_batch_lookup=geo_service.lookup_batch,
app_db=None, app_db=request.app.state.db,
origin=origin, origin=origin,
) )
@@ -181,6 +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: 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.",
@@ -212,7 +217,13 @@ async def get_ban_trend(
""" """
socket_path: str = request.app.state.settings.fail2ban_socket socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.ban_trend(socket_path, range, origin=origin) return await ban_service.ban_trend(
socket_path,
range,
source=source,
app_db=request.app.state.db,
origin=origin,
)
@router.get( @router.get(
@@ -224,6 +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: 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.",
@@ -248,4 +260,10 @@ async def get_bans_by_jail(
""" """
socket_path: str = request.app.state.settings.fail2ban_socket socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.bans_by_jail(socket_path, range, origin=origin) return await ban_service.bans_by_jail(
socket_path,
range,
source=source,
app_db=request.app.state.db,
origin=origin,
)

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,6 +56,10 @@ 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: 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( page_size: int = Query(
default=_DEFAULT_PAGE_SIZE, default=_DEFAULT_PAGE_SIZE,
@@ -94,9 +98,47 @@ async def get_history(
jail=jail, jail=jail,
ip_filter=ip, ip_filter=ip,
origin=origin, origin=origin,
source=source,
page=page, page=page,
page_size=page_size, page_size=page_size,
geo_enricher=_enricher, geo_enricher=_enricher,
db=request.app.state.db,
)
@router.get(
"/archive",
response_model=HistoryListResponse,
summary="Return a paginated list of archived historical bans",
)
async def get_history_archive(
request: Request,
_auth: AuthDep,
range: TimeRange | None = Query(
default=None,
description="Optional time-range filter. Omit for all-time.",
),
jail: str | None = Query(default=None, description="Restrict results to this jail name."),
ip: str | None = Query(default=None, description="Restrict results to IPs matching this prefix."),
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 (max 500)."),
) -> HistoryListResponse:
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
async def _enricher(addr: str) -> geo_service.GeoInfo | None:
return await geo_service.lookup(addr, http_session)
return await history_service.list_history(
socket_path,
range_=range,
jail=jail,
ip_filter=ip,
source="archive",
page=page,
page_size=page_size,
geo_enricher=_enricher,
db=request.app.state.db,
) )

View File

@@ -112,6 +112,7 @@ async def list_bans(
socket_path: str, socket_path: str,
range_: TimeRange, range_: TimeRange,
*, *,
source: str = "fail2ban",
page: int = 1, page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE, page_size: int = _DEFAULT_PAGE_SIZE,
http_session: aiohttp.ClientSession | None = None, http_session: aiohttp.ClientSession | None = None,
@@ -160,24 +161,41 @@ async def list_bans(
since: int = _since_unix(range_) since: int = _since_unix(range_)
effective_page_size: int = min(page_size, _MAX_PAGE_SIZE) effective_page_size: int = min(page_size, _MAX_PAGE_SIZE)
offset: int = (page - 1) * effective_page_size offset: int = (page - 1) * effective_page_size
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path) if source not in ("fail2ban", "archive"):
log.info( raise ValueError(f"Unsupported source: {source!r}")
"ban_service_list_bans",
db_path=db_path,
since=since,
range=range_,
origin=origin,
)
rows, total = await fail2ban_db_repo.get_currently_banned( if source == "archive":
db_path=db_path, if app_db is None:
since=since, raise ValueError("app_db must be provided when source is 'archive'")
origin=origin,
limit=effective_page_size, from app.repositories.history_archive_repo import get_archived_history
offset=offset,
) rows, total = await get_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
page=page,
page_size=effective_page_size,
)
else:
db_path: str = await get_fail2ban_db_path(socket_path)
log.info(
"ban_service_list_bans",
db_path=db_path,
since=since,
range=range_,
origin=origin,
)
rows, total = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=since,
origin=origin,
limit=effective_page_size,
offset=offset,
)
# Batch-resolve geo data for all IPs on this page in a single API call. # Batch-resolve geo data for all IPs on this page in a single API call.
# This avoids hitting the 45 req/min single-IP rate limit when the # This avoids hitting the 45 req/min single-IP rate limit when the
@@ -192,11 +210,19 @@ async def list_bans(
items: list[DashboardBanItem] = [] items: list[DashboardBanItem] = []
for row in rows: for row in rows:
jail: str = row.jail if source == "archive":
ip: str = row.ip jail = str(row["jail"])
banned_at: str = ts_to_iso(row.timeofban) ip = str(row["ip"])
ban_count: int = row.bancount banned_at = ts_to_iso(int(row["timeofban"]))
matches, _ = parse_data_json(row.data) ban_count = int(row["bancount"])
matches, _ = parse_data_json(row["data"])
else:
jail = row.jail
ip = row.ip
banned_at = ts_to_iso(row.timeofban)
ban_count = row.bancount
matches, _ = parse_data_json(row.data)
service: str | None = matches[0] if matches else None service: str | None = matches[0] if matches else None
country_code: str | None = None country_code: str | None = None
@@ -256,6 +282,8 @@ _MAX_COMPANION_BANS: int = 200
async def bans_by_country( async def bans_by_country(
socket_path: str, socket_path: str,
range_: TimeRange, range_: TimeRange,
*,
source: str = "fail2ban",
http_session: aiohttp.ClientSession | None = None, http_session: aiohttp.ClientSession | None = None,
geo_cache_lookup: GeoCacheLookup | None = None, geo_cache_lookup: GeoCacheLookup | None = None,
geo_batch_lookup: GeoBatchLookup | None = None, geo_batch_lookup: GeoBatchLookup | None = None,
@@ -300,41 +328,80 @@ async def bans_by_country(
""" """
since: int = _since_unix(range_) since: int = _since_unix(range_)
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path)
log.info(
"ban_service_bans_by_country",
db_path=db_path,
since=since,
range=range_,
origin=origin,
)
# Total count and companion rows reuse the same SQL query logic. if source not in ("fail2ban", "archive"):
# Passing limit=0 returns only the total from the count query. raise ValueError(f"Unsupported source: {source!r}")
_, total = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=since,
origin=origin,
limit=0,
offset=0,
)
agg_rows = await fail2ban_db_repo.get_ban_event_counts( if source == "archive":
db_path=db_path, if app_db is None:
since=since, raise ValueError("app_db must be provided when source is 'archive'")
origin=origin,
)
companion_rows, _ = await fail2ban_db_repo.get_currently_banned( from app.repositories.history_archive_repo import (
db_path=db_path, get_all_archived_history,
since=since, get_archived_history,
origin=origin, )
limit=_MAX_COMPANION_BANS,
offset=0,
)
unique_ips: list[str] = [r.ip for r in agg_rows] all_rows = await get_all_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
)
total = len(all_rows)
# companion rows for the table should be most recent
companion_rows, _ = await get_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
page=1,
page_size=_MAX_COMPANION_BANS,
)
agg_rows = {}
for row in all_rows:
ip = str(row["ip"])
agg_rows[ip] = agg_rows.get(ip, 0) + 1
unique_ips = list(agg_rows.keys())
else:
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path)
log.info(
"ban_service_bans_by_country",
db_path=db_path,
since=since,
range=range_,
origin=origin,
)
# Total count and companion rows reuse the same SQL query logic.
# Passing limit=0 returns only the total from the count query.
_, total = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=since,
origin=origin,
limit=0,
offset=0,
)
agg_rows = await fail2ban_db_repo.get_ban_event_counts(
db_path=db_path,
since=since,
origin=origin,
)
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
db_path=db_path,
since=since,
origin=origin,
limit=_MAX_COMPANION_BANS,
offset=0,
)
unique_ips = [r.ip for r in agg_rows]
geo_map: dict[str, GeoInfo] = {} geo_map: dict[str, GeoInfo] = {}
if http_session is not None and unique_ips and geo_cache_lookup is not None: if http_session is not None and unique_ips and geo_cache_lookup is not None:
@@ -371,12 +438,28 @@ async def bans_by_country(
countries: dict[str, int] = {} countries: dict[str, int] = {}
country_names: dict[str, str] = {} country_names: dict[str, str] = {}
for agg_row in agg_rows: if source == "archive":
ip: str = agg_row.ip agg_items = [
{
"ip": ip,
"event_count": count,
}
for ip, count in agg_rows.items()
]
else:
agg_items = agg_rows
for agg_row in agg_items:
if source == "archive":
ip = agg_row["ip"]
event_count = agg_row["event_count"]
else:
ip = agg_row.ip
event_count = agg_row.event_count
geo = geo_map.get(ip) geo = geo_map.get(ip)
cc: str | None = geo.country_code if geo else None cc: str | None = geo.country_code if geo else None
cn: str | None = geo.country_name if geo else None cn: str | None = geo.country_name if geo else None
event_count: int = agg_row.event_count
if cc: if cc:
countries[cc] = countries.get(cc, 0) + event_count countries[cc] = countries.get(cc, 0) + event_count
@@ -386,26 +469,38 @@ async def bans_by_country(
# Build companion table from recent rows (geo already cached from batch step). # Build companion table from recent rows (geo already cached from batch step).
bans: list[DashboardBanItem] = [] bans: list[DashboardBanItem] = []
for companion_row in companion_rows: for companion_row in companion_rows:
ip = companion_row.ip if source == "archive":
ip = companion_row["ip"]
jail = companion_row["jail"]
banned_at = ts_to_iso(int(companion_row["timeofban"]))
ban_count = int(companion_row["bancount"])
service = None
else:
ip = companion_row.ip
jail = companion_row.jail
banned_at = ts_to_iso(companion_row.timeofban)
ban_count = companion_row.bancount
matches, _ = parse_data_json(companion_row.data)
service = matches[0] if matches else None
geo = geo_map.get(ip) geo = geo_map.get(ip)
cc = geo.country_code if geo else None cc = geo.country_code if geo else None
cn = geo.country_name if geo else None cn = geo.country_name if geo else None
asn: str | None = geo.asn if geo else None asn: str | None = geo.asn if geo else None
org: str | None = geo.org if geo else None org: str | None = geo.org if geo else None
matches, _ = parse_data_json(companion_row.data)
bans.append( bans.append(
DashboardBanItem( DashboardBanItem(
ip=ip, ip=ip,
jail=companion_row.jail, jail=jail,
banned_at=ts_to_iso(companion_row.timeofban), banned_at=banned_at,
service=matches[0] if matches else None, service=service,
country_code=cc, country_code=cc,
country_name=cn, country_name=cn,
asn=asn, asn=asn,
org=org, org=org,
ban_count=companion_row.bancount, ban_count=ban_count,
origin=_derive_origin(companion_row.jail), origin=_derive_origin(jail),
) )
) )
@@ -426,6 +521,8 @@ async def ban_trend(
socket_path: str, socket_path: str,
range_: TimeRange, range_: TimeRange,
*, *,
source: str = "fail2ban",
app_db: aiosqlite.Connection | None = None,
origin: BanOrigin | None = None, origin: BanOrigin | None = None,
) -> BanTrendResponse: ) -> BanTrendResponse:
"""Return ban counts aggregated into equal-width time buckets. """Return ban counts aggregated into equal-width time buckets.
@@ -457,26 +554,58 @@ async def ban_trend(
since: int = _since_unix(range_) since: int = _since_unix(range_)
bucket_secs: int = BUCKET_SECONDS[range_] bucket_secs: int = BUCKET_SECONDS[range_]
num_buckets: int = bucket_count(range_) num_buckets: int = bucket_count(range_)
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path) if source not in ("fail2ban", "archive"):
log.info( raise ValueError(f"Unsupported source: {source!r}")
"ban_service_ban_trend",
db_path=db_path,
since=since,
range=range_,
origin=origin,
bucket_secs=bucket_secs,
num_buckets=num_buckets,
)
counts = await fail2ban_db_repo.get_ban_counts_by_bucket( if source == "archive":
db_path=db_path, if app_db is None:
since=since, raise ValueError("app_db must be provided when source is 'archive'")
bucket_secs=bucket_secs,
num_buckets=num_buckets, from app.repositories.history_archive_repo import get_all_archived_history
origin=origin,
) all_rows = await get_all_archived_history(
db=app_db,
since=since,
origin=origin,
action="ban",
)
counts: list[int] = [0] * num_buckets
for row in all_rows:
timeofban = int(row["timeofban"])
bucket_index = int((timeofban - since) / bucket_secs)
if 0 <= bucket_index < num_buckets:
counts[bucket_index] += 1
log.info(
"ban_service_ban_trend",
source=source,
since=since,
range=range_,
origin=origin,
bucket_secs=bucket_secs,
num_buckets=num_buckets,
)
else:
db_path: str = await get_fail2ban_db_path(socket_path)
log.info(
"ban_service_ban_trend",
db_path=db_path,
since=since,
range=range_,
origin=origin,
bucket_secs=bucket_secs,
num_buckets=num_buckets,
)
counts = await fail2ban_db_repo.get_ban_counts_by_bucket(
db_path=db_path,
since=since,
bucket_secs=bucket_secs,
num_buckets=num_buckets,
origin=origin,
)
buckets: list[BanTrendBucket] = [ buckets: list[BanTrendBucket] = [
BanTrendBucket( BanTrendBucket(
@@ -501,6 +630,8 @@ async def bans_by_jail(
socket_path: str, socket_path: str,
range_: TimeRange, range_: TimeRange,
*, *,
source: str = "fail2ban",
app_db: aiosqlite.Connection | None = None,
origin: BanOrigin | None = None, origin: BanOrigin | None = None,
) -> BansByJailResponse: ) -> BansByJailResponse:
"""Return ban counts aggregated per jail for the selected time window. """Return ban counts aggregated per jail for the selected time window.
@@ -522,38 +653,75 @@ async def bans_by_jail(
sorted descending and the total ban count. sorted descending and the total ban count.
""" """
since: int = _since_unix(range_) since: int = _since_unix(range_)
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path) if source not in ("fail2ban", "archive"):
log.debug( raise ValueError(f"Unsupported source: {source!r}")
"ban_service_bans_by_jail",
db_path=db_path,
since=since,
since_iso=ts_to_iso(since),
range=range_,
origin=origin,
)
total, jail_counts = await fail2ban_db_repo.get_bans_by_jail( if source == "archive":
db_path=db_path, if app_db is None:
since=since, raise ValueError("app_db must be provided when source is 'archive'")
origin=origin,
)
# Diagnostic guard: if zero results were returned, check whether the table from app.repositories.history_archive_repo import get_all_archived_history
# has *any* rows and log a warning with min/max timeofban so operators can
# diagnose timezone or filter mismatches from logs. all_rows = await get_all_archived_history(
if total == 0: db=app_db,
table_row_count, min_timeofban, max_timeofban = await fail2ban_db_repo.get_bans_table_summary(db_path) since=since,
if table_row_count > 0: origin=origin,
log.warning( action="ban",
"ban_service_bans_by_jail_empty_despite_data", )
table_row_count=table_row_count,
min_timeofban=min_timeofban, jail_counter: dict[str, int] = {}
max_timeofban=max_timeofban, for row in all_rows:
since=since, jail_name = str(row["jail"])
range=range_, jail_counter[jail_name] = jail_counter.get(jail_name, 0) + 1
)
total = sum(jail_counter.values())
jail_counts = [
JailBanCountModel(jail=jail_name, count=count)
for jail_name, count in sorted(jail_counter.items(), key=lambda x: x[1], reverse=True)
]
log.debug(
"ban_service_bans_by_jail",
source=source,
since=since,
since_iso=ts_to_iso(since),
range=range_,
origin=origin,
)
else:
origin_clause, origin_params = _origin_sql_filter(origin)
db_path: str = await get_fail2ban_db_path(socket_path)
log.debug(
"ban_service_bans_by_jail",
db_path=db_path,
since=since,
since_iso=ts_to_iso(since),
range=range_,
origin=origin,
)
total, jail_counts = await fail2ban_db_repo.get_bans_by_jail(
db_path=db_path,
since=since,
origin=origin,
)
# Diagnostic guard: if zero results were returned, check whether the table
# has *any* rows and log a warning with min/max timeofban so operators can
# diagnose timezone or filter mismatches from logs.
if total == 0:
table_row_count, min_timeofban, max_timeofban = await fail2ban_db_repo.get_bans_table_summary(db_path)
if table_row_count > 0:
log.warning(
"ban_service_bans_by_jail_empty_despite_data",
table_row_count=table_row_count,
min_timeofban=min_timeofban,
max_timeofban=max_timeofban,
since=since,
range=range_,
)
log.debug( log.debug(
"ban_service_bans_by_jail_result", "ban_service_bans_by_jail_result",

View File

@@ -351,8 +351,8 @@ async def update_jail_config(
await _set("datepattern", update.date_pattern) await _set("datepattern", update.date_pattern)
if update.dns_mode is not None: if update.dns_mode is not None:
await _set("usedns", update.dns_mode) await _set("usedns", update.dns_mode)
if update.backend is not None: # backend is managed by fail2ban and cannot be changed at runtime by API.
await _set("backend", update.backend) # This field is therefore ignored during updates.
if update.log_encoding is not None: if update.log_encoding is not None:
await _set("logencoding", update.log_encoding) await _set("logencoding", update.log_encoding)
if update.prefregex is not None: if update.prefregex is not None:

View File

@@ -16,6 +16,8 @@ from typing import TYPE_CHECKING
import structlog import structlog
if TYPE_CHECKING: if TYPE_CHECKING:
import aiosqlite
from app.models.geo import GeoEnricher from app.models.geo import GeoEnricher
from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
@@ -63,9 +65,11 @@ async def list_history(
jail: str | None = None, jail: str | None = None,
ip_filter: str | None = None, ip_filter: str | None = None,
origin: BanOrigin | None = None, origin: BanOrigin | None = None,
source: str = "fail2ban",
page: int = 1, page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE, page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: GeoEnricher | None = None, geo_enricher: GeoEnricher | None = None,
db: aiosqlite.Connection | None = None,
) -> HistoryListResponse: ) -> HistoryListResponse:
"""Return a paginated list of historical ban records with optional filters. """Return a paginated list of historical ban records with optional filters.
@@ -104,55 +108,111 @@ async def list_history(
page=page, page=page,
) )
rows, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=since,
jail=jail,
ip_filter=ip_filter,
origin=origin,
page=page,
page_size=effective_page_size,
)
items: list[HistoryBanItem] = [] items: list[HistoryBanItem] = []
for row in rows: total: int
jail_name: str = row.jail
ip: str = row.ip
banned_at: str = ts_to_iso(row.timeofban)
ban_count: int = row.bancount
matches, failures = parse_data_json(row.data)
country_code: str | None = None if source == "archive":
country_name: str | None = None if db is None:
asn: str | None = None raise ValueError("db must be provided when source is 'archive'")
org: str | None = None
if geo_enricher is not None: from app.repositories.history_archive_repo import get_archived_history
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed", ip=ip)
items.append( archived_rows, total = await get_archived_history(
HistoryBanItem( db=db,
ip=ip, since=since,
jail=jail_name, jail=jail,
banned_at=banned_at, ip_filter=ip_filter,
ban_count=ban_count, page=page,
failures=failures, page_size=effective_page_size,
matches=matches,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
)
) )
for row in archived_rows:
jail_name = row["jail"]
ip = row["ip"]
banned_at = ts_to_iso(int(row["timeofban"]))
ban_count = int(row["bancount"])
matches, failures = parse_data_json(row["data"])
# archive records may include actions; we treat all as history
country_code = None
country_name = None
asn = None
org = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed", ip=ip)
items.append(
HistoryBanItem(
ip=ip,
jail=jail_name,
banned_at=banned_at,
ban_count=ban_count,
failures=failures,
matches=matches,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
)
)
else:
rows, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=since,
jail=jail,
ip_filter=ip_filter,
origin=origin,
page=page,
page_size=effective_page_size,
)
for row in rows:
jail_name: str = row.jail
ip: str = row.ip
banned_at: str = ts_to_iso(row.timeofban)
ban_count: int = row.bancount
matches, failures = parse_data_json(row.data)
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
if geo_enricher is not None:
try:
geo = await geo_enricher(ip)
if geo is not None:
country_code = geo.country_code
country_name = geo.country_name
asn = geo.asn
org = geo.org
except Exception: # noqa: BLE001
log.warning("history_service_geo_lookup_failed", ip=ip)
items.append(
HistoryBanItem(
ip=ip,
jail=jail_name,
banned_at=banned_at,
ban_count=ban_count,
failures=failures,
matches=matches,
country_code=country_code,
country_name=country_name,
asn=asn,
org=org,
)
)
return HistoryListResponse( return HistoryListResponse(
items=items, items=items,
total=total, total=total,

View File

@@ -160,8 +160,12 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
db_max_matches=db_max_matches, db_max_matches=db_max_matches,
) )
log.info("server_settings_fetched") warnings: dict[str, bool] = {
return ServerSettingsResponse(settings=settings) "db_purge_age_too_low": db_purge_age < 86400,
}
log.info("server_settings_fetched", db_purge_age=db_purge_age, warnings=warnings)
return ServerSettingsResponse(settings=settings, warnings=warnings)
async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None: async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None:

View File

@@ -0,0 +1,109 @@
"""History sync background task.
Periodically copies new records from the fail2ban sqlite database into the
BanGUI application archive table to prevent gaps when fail2ban purges old rows.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
import structlog
from app.repositories import fail2ban_db_repo
from app.utils.fail2ban_db_utils import get_fail2ban_db_path
if TYPE_CHECKING: # pragma: no cover
from fastapi import FastAPI
log: structlog.stdlib.BoundLogger = structlog.get_logger()
#: Stable APScheduler job id.
JOB_ID: str = "history_sync"
#: Interval in seconds between sync runs.
HISTORY_SYNC_INTERVAL: int = 300
#: Backfill window when archive is empty (seconds).
BACKFILL_WINDOW: int = 648000
async def _get_last_archive_ts(db) -> int | None:
async with db.execute("SELECT MAX(timeofban) FROM history_archive") as cur:
row = await cur.fetchone()
if row is None or row[0] is None:
return None
return int(row[0])
async def _run_sync(app: FastAPI) -> None:
db = app.state.db
socket_path: str = app.state.settings.fail2ban_socket
try:
last_ts = await _get_last_archive_ts(db)
now_ts = int(datetime.datetime.now(datetime.UTC).timestamp())
if last_ts is None:
last_ts = now_ts - BACKFILL_WINDOW
log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW)
per_page = 500
next_since = last_ts + 1
total_synced = 0
while True:
fail2ban_db_path = await get_fail2ban_db_path(socket_path)
rows, total = await fail2ban_db_repo.get_history_page(
db_path=fail2ban_db_path,
since=next_since,
page=1,
page_size=per_page,
)
if not rows:
break
from app.repositories.history_archive_repo import archive_ban_event
for row in rows:
await archive_ban_event(
db=db,
jail=row.jail,
ip=row.ip,
timeofban=row.timeofban,
bancount=row.bancount,
data=row.data,
action="ban",
)
total_synced += 1
# Continue where we left off by max timeofban + 1.
max_time = max(row.timeofban for row in rows)
next_since = max_time + 1
if len(rows) < per_page:
break
log.info("history_sync_complete", synced=total_synced)
except Exception:
log.exception("history_sync_failed")
def register(app: FastAPI) -> None:
"""Register the history sync periodic job.
Should be called after scheduler startup, from the lifespan handler.
"""
app.state.scheduler.add_job(
_run_sync,
trigger="interval",
seconds=HISTORY_SYNC_INTERVAL,
kwargs={"app": app},
id=JOB_ID,
replace_existing=True,
next_run_time=datetime.datetime.now(tz=datetime.UTC),
)
log.info("history_sync_scheduled", interval_seconds=HISTORY_SYNC_INTERVAL)

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "bangui-backend" name = "bangui-backend"
version = "0.9.8" 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

@@ -0,0 +1,60 @@
"""Tests for history_archive_repo."""
from __future__ import annotations
import time
from pathlib import Path
import aiosqlite
import pytest
from app.db import init_db
from app.repositories.history_archive_repo import archive_ban_event, get_archived_history, purge_archived_history
@pytest.fixture
async def app_db(tmp_path: Path) -> str:
path = str(tmp_path / "app.db")
async with aiosqlite.connect(path) as db:
db.row_factory = aiosqlite.Row
await init_db(db)
return path
@pytest.mark.asyncio
async def test_archive_ban_event_deduplication(app_db: str) -> None:
async with aiosqlite.connect(app_db) as db:
# first insert should add
inserted = await archive_ban_event(db, "sshd", "1.1.1.1", 1000, 1, "{}", "ban")
assert inserted
# duplicate event is ignored
inserted = await archive_ban_event(db, "sshd", "1.1.1.1", 1000, 1, "{}", "ban")
assert not inserted
@pytest.mark.asyncio
async def test_get_archived_history_filtering_and_pagination(app_db: str) -> None:
async with aiosqlite.connect(app_db) as db:
await archive_ban_event(db, "sshd", "1.1.1.1", 1000, 1, "{}", "ban")
await archive_ban_event(db, "nginx", "2.2.2.2", 2000, 1, "{}", "ban")
rows, total = await get_archived_history(db, jail="sshd")
assert total == 1
assert rows[0]["ip"] == "1.1.1.1"
rows, total = await get_archived_history(db, page=1, page_size=1)
assert total == 2
assert len(rows) == 1
@pytest.mark.asyncio
async def test_purge_archived_history(app_db: str) -> None:
now = int(time.time())
async with aiosqlite.connect(app_db) as db:
await archive_ban_event(db, "sshd", "1.1.1.1", now - 3000, 1, "{}", "ban")
await archive_ban_event(db, "sshd", "1.1.1.2", now - 1000, 1, "{}", "ban")
deleted = await purge_archived_history(db, age_seconds=2000)
assert deleted == 1
rows, total = await get_archived_history(db)
assert total == 1

View File

@@ -290,6 +290,17 @@ class TestDashboardBans:
called_range = mock_list.call_args[0][1] called_range = mock_list.call_args[0][1]
assert called_range == "7d" assert called_range == "7d"
async def test_accepts_source_param(
self, dashboard_client: AsyncClient
) -> None:
"""The ``source`` query parameter is forwarded to ban_service."""
mock_list = AsyncMock(return_value=_make_ban_list_response())
with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list):
await dashboard_client.get("/api/dashboard/bans?source=archive")
called_source = mock_list.call_args[1]["source"]
assert called_source == "archive"
async def test_empty_ban_list_returns_zero_total( async def test_empty_ban_list_returns_zero_total(
self, dashboard_client: AsyncClient self, dashboard_client: AsyncClient
) -> None: ) -> None:
@@ -417,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:
@@ -492,6 +512,16 @@ class TestDashboardBansOriginField:
origins = {ban["origin"] for ban in bans} origins = {ban["origin"] for ban in bans}
assert origins == {"blocklist", "selfblock"} assert origins == {"blocklist", "selfblock"}
async def test_bans_by_country_source_param_forwarded(
self, dashboard_client: AsyncClient
) -> None:
"""The ``source`` query parameter is forwarded to bans_by_country."""
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn):
await dashboard_client.get("/api/dashboard/bans/by-country?source=archive")
assert mock_fn.call_args[1]["source"] == "archive"
async def test_blocklist_origin_serialised_correctly( async def test_blocklist_origin_serialised_correctly(
self, dashboard_client: AsyncClient self, dashboard_client: AsyncClient
) -> None: ) -> None:
@@ -701,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
@@ -836,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

@@ -225,6 +225,32 @@ class TestHistoryList:
_args, kwargs = mock_fn.call_args _args, kwargs = mock_fn.call_args
assert kwargs.get("origin") == "blocklist" assert kwargs.get("origin") == "blocklist"
async def test_forwards_source_filter(self, history_client: AsyncClient) -> None:
"""The ``source`` query parameter is forwarded to the service."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?source=archive")
_args, kwargs = mock_fn.call_args
assert kwargs.get("source") == "archive"
async def test_archive_route_forces_source_archive(
self, history_client: AsyncClient
) -> None:
"""GET /api/history/archive should call list_history with source='archive'."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history/archive")
_args, kwargs = mock_fn.call_args
assert kwargs.get("source") == "archive"
async def test_empty_result(self, history_client: AsyncClient) -> None: async def test_empty_result(self, history_client: AsyncClient) -> None:
"""An empty history returns items=[] and total=0.""" """An empty history returns items=[] and total=0."""
with patch( with patch(

View File

@@ -68,7 +68,8 @@ def _make_settings() -> ServerSettingsResponse:
db_path="/var/lib/fail2ban/fail2ban.sqlite3", db_path="/var/lib/fail2ban/fail2ban.sqlite3",
db_purge_age=86400, db_purge_age=86400,
db_max_matches=10, db_max_matches=10,
) ),
warnings={"db_purge_age_too_low": False},
) )
@@ -93,6 +94,7 @@ class TestGetServerSettings:
data = resp.json() data = resp.json()
assert data["settings"]["log_level"] == "INFO" assert data["settings"]["log_level"] == "INFO"
assert data["settings"]["db_purge_age"] == 86400 assert data["settings"]["db_purge_age"] == 86400
assert data["warnings"]["db_purge_age_too_low"] is False
async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None: async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None:
"""GET /api/server/settings returns 401 without session.""" """GET /api/server/settings returns 401 without session."""

View File

@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch
import aiosqlite import aiosqlite
import pytest import pytest
from app.db import init_db
from app.services import ban_service from app.services import ban_service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -143,6 +144,29 @@ async def empty_f2b_db_path(tmp_path: Path) -> str:
return path return path
@pytest.fixture
async def app_db_with_archive(tmp_path: Path) -> aiosqlite.Connection:
"""Return an app database connection pre-populated with archived ban rows."""
db_path = str(tmp_path / "app_archive.db")
db = await aiosqlite.connect(db_path)
db.row_factory = aiosqlite.Row
await init_db(db)
await db.execute(
"INSERT INTO history_archive (jail, ip, timeofban, bancount, data, action) VALUES (?, ?, ?, ?, ?, ?)",
("sshd", "1.2.3.4", _ONE_HOUR_AGO, 1, '{"matches": ["fail"], "failures": 1}', "ban"),
)
await db.execute(
"INSERT INTO history_archive (jail, ip, timeofban, bancount, data, action) VALUES (?, ?, ?, ?, ?, ?)",
("nginx", "5.6.7.8", _ONE_HOUR_AGO, 1, '{"matches": ["fail"], "failures": 2}', "ban"),
)
await db.commit()
yield db
await db.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# list_bans — happy path # list_bans — happy path
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -233,6 +257,20 @@ class TestListBansHappyPath:
assert result.total == 3 assert result.total == 3
async def test_source_archive_reads_from_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""Using source='archive' reads from the BanGUI archive table."""
result = await ban_service.list_bans(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert result.total == 2
assert {item.ip for item in result.items} == {"1.2.3.4", "5.6.7.8"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# list_bans — geo enrichment # list_bans — geo enrichment
@@ -616,6 +654,20 @@ class TestOriginFilter:
assert result.total == 3 assert result.total == 3
async def test_bans_by_country_source_archive_reads_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""``bans_by_country`` accepts source='archive' and reads archived rows."""
result = await ban_service.bans_by_country(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert result.total == 2
assert len(result.bans) == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# bans_by_country — background geo resolution (Task 3) # bans_by_country — background geo resolution (Task 3)
@@ -802,6 +854,19 @@ class TestBanTrend:
timestamps = [b.timestamp for b in result.buckets] timestamps = [b.timestamp for b in result.buckets]
assert timestamps == sorted(timestamps) assert timestamps == sorted(timestamps)
async def test_ban_trend_source_archive_reads_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""``ban_trend`` accepts source='archive' and uses archived rows."""
result = await ban_service.ban_trend(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert sum(b.count for b in result.buckets) == 2
async def test_bans_counted_in_correct_bucket(self, tmp_path: Path) -> None: async def test_bans_counted_in_correct_bucket(self, tmp_path: Path) -> None:
"""A ban at a known time appears in the expected bucket.""" """A ban at a known time appears in the expected bucket."""
import time as _time import time as _time
@@ -1018,6 +1083,20 @@ class TestBansByJail:
assert result.total == 3 assert result.total == 3
assert len(result.jails) == 3 assert len(result.jails) == 3
async def test_bans_by_jail_source_archive_reads_archive(
self, app_db_with_archive: aiosqlite.Connection
) -> None:
"""``bans_by_jail`` accepts source='archive' and aggregates archived rows."""
result = await ban_service.bans_by_jail(
"/fake/sock",
"24h",
source="archive",
app_db=app_db_with_archive,
)
assert result.total == 2
assert any(j.jail == "sshd" for j in result.jails)
async def test_diagnostic_warning_when_zero_results_despite_data( async def test_diagnostic_warning_when_zero_results_despite_data(
self, tmp_path: Path self, tmp_path: Path
) -> None: ) -> None:

View File

@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch
import aiosqlite import aiosqlite
import pytest import pytest
from app.db import init_db
from app.services import history_service from app.services import history_service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -264,6 +265,31 @@ class TestListHistory:
assert result.page == 1 assert result.page == 1
assert result.page_size == 2 assert result.page_size == 2
async def test_source_archive_reads_from_archive(self, f2b_db_path: str, tmp_path: Path) -> None:
"""Using source='archive' reads from the BanGUI archive table."""
app_db_path = str(tmp_path / "app_archive.db")
async with aiosqlite.connect(app_db_path) as db:
db.row_factory = aiosqlite.Row
await init_db(db)
await db.execute(
"INSERT INTO history_archive (jail, ip, timeofban, bancount, data, action) VALUES (?, ?, ?, ?, ?, ?)",
("sshd", "10.0.0.1", _ONE_HOUR_AGO, 1, '{"matches": [], "failures": 0}', "ban"),
)
await db.commit()
with patch(
"app.services.history_service.get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket",
source="archive",
db=db,
)
assert result.total == 1
assert result.items[0].ip == "10.0.0.1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_ip_detail tests # get_ip_detail tests

View File

@@ -63,6 +63,16 @@ class TestGetSettings:
assert result.settings.log_target == "/var/log/fail2ban.log" assert result.settings.log_target == "/var/log/fail2ban.log"
assert result.settings.db_purge_age == 86400 assert result.settings.db_purge_age == 86400
assert result.settings.db_max_matches == 10 assert result.settings.db_max_matches == 10
assert result.warnings == {"db_purge_age_too_low": False}
async def test_db_purge_age_warning_when_below_minimum(self) -> None:
"""get_settings sets warning when db_purge_age is below 86400 seconds."""
responses = {**_DEFAULT_RESPONSES, "get|dbpurgeage": (0, 3600)}
with _patch_client(responses):
result = await server_service.get_settings(_SOCKET)
assert result.settings.db_purge_age == 3600
assert result.warnings == {"db_purge_age_too_low": True}
async def test_db_path_parsed(self) -> None: async def test_db_path_parsed(self) -> None:
"""get_settings returns the correct database file path.""" """get_settings returns the correct database file path."""

View File

@@ -0,0 +1,59 @@
"""Tests for history_sync task registration."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
from app.tasks import history_sync
class TestHistorySyncTask:
async def test_register_schedules_job(self) -> None:
fake_scheduler = MagicMock()
class FakeState:
pass
class FakeSettings:
fail2ban_socket = "/tmp/fake.sock"
app = type("FakeApp", (), {})()
app.state = FakeState()
app.state.scheduler = fake_scheduler
app.state.settings = FakeSettings()
history_sync.register(app)
fake_scheduler.add_job.assert_called_once()
called_args, called_kwargs = fake_scheduler.add_job.call_args
assert called_kwargs["id"] == history_sync.JOB_ID
assert called_kwargs["kwargs"]["app"] == app
async def test_backfill_window_is_7_5_days(self) -> None:
assert history_sync.BACKFILL_WINDOW == 648000
async def test_sync_uses_strict_since_after_restart(self) -> None:
fake_app = type("FakeApp", (), {})()
fake_app.state = type("FakeState", (), {})()
fake_app.state.settings = type("FakeSettings", (), {})()
fake_app.state.settings.fail2ban_socket = "/tmp/fake.sock"
fake_app.state.db = MagicMock()
async def fake_get_history_page(*, db_path: str, since: int, page: int, page_size: int, **kwargs):
assert since == 1001
return [], 0
async def fake_get_fail2ban_db_path(socket_path: str) -> str:
return "/tmp/fake.sqlite3"
with patch(
"app.tasks.history_sync._get_last_archive_ts",
new=AsyncMock(return_value=1000),
), patch(
"app.tasks.history_sync.get_fail2ban_db_path",
new=fake_get_fail2ban_db_path,
), patch(
"app.tasks.history_sync.fail2ban_db_repo.get_history_page",
new=fake_get_history_page,
):
await history_sync._run_sync(fake_app)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import re
import app import app
@@ -13,3 +14,15 @@ def test_app_version_matches_docker_version() -> None:
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v") expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
assert app.__version__ == expected assert app.__version__ == expected
def test_backend_pyproject_version_matches_docker_version() -> None:
repo_root = Path(__file__).resolve().parents[2]
version_file = repo_root / "Docker" / "VERSION"
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
pyproject_file = repo_root / "backend" / "pyproject.toml"
text = pyproject_file.read_text(encoding="utf-8")
match = re.search(r"^version\s*=\s*\"([^\"]+)\"", text, re.MULTILINE)
assert match is not None, "backend/pyproject.toml must contain a version entry"
assert match.group(1) == expected

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.10", "version": "0.9.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bangui-frontend", "name": "bangui-frontend",
"version": "0.9.10", "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.13", "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,195 +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>; cc: string;
countryNames?: Record<string, string>; count: number;
selectedCountry: string | null; name: string;
onSelectCountry: (cc: string | null) => void; x: number;
thresholdLow: number; y: number;
thresholdMedium: number; } | null;
thresholdHigh: number;
}
function GeoLayer({ interface WorldMapProps {
countries,
countryNames,
selectedCountry,
onSelectCountry,
thresholdLow,
thresholdMedium,
thresholdHigh,
}: GeoLayerProps): React.JSX.Element {
const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL });
const [tooltip, setTooltip] = useState<
| {
cc: string;
count: number;
name: string;
x: number;
y: number;
}
| null
>(null);
const handleClick = useCallback(
(cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc);
},
[selectedCountry, onSelectCountry],
);
if (geographies.length === 0) return <></>;
// react-simple-maps types declare path as always defined, but it can be null
// during initial render before MapProvider context initializes. Cast to reflect
// the true runtime type and allow safe null checking.
const safePath = path as unknown as typeof path | null;
return (
<>
{(geographies as { rsmKey: string; id: string | number }[]).map(
(geo) => {
const numericId = String(geo.id);
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
const isSelected = cc !== null && selectedCountry === cc;
// Compute the fill color based on ban count
const fillColor = getBanCountColor(
count,
thresholdLow,
thresholdMedium,
thresholdHigh,
);
// Only calculate centroid if path is available
let cx: number | undefined;
let cy: number | undefined;
if (safePath != null) {
const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects);
[cx, cy] = centroid;
}
return (
<g
key={geo.rsmKey}
style={{ cursor: cc ? "pointer" : "default" }}
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);
}}
>
<Geography
geography={geo}
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. */
@@ -289,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);
const handleZoomOut = (): void => { useEffect(() => {
setZoom((z) => Math.max(z - 0.5, 1)); zoomRef.current = zoom;
}; }, [zoom]);
const handleResetView = (): void => { useEffect(() => {
setZoom(1); 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 handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
const drag = dragStateRef.current;
if (!drag?.active) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
drag.moved = true;
clickSuppressedRef.current = true;
}
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
}, []);
const handlePointerUp = useCallback((event: React.PointerEvent<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
@@ -311,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"
> >
@@ -327,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"
> >
@@ -337,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"
> >
@@ -345,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;
minZoom={1} const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
maxZoom={8} const pathString = pathGenerator(featureItem) ?? "";
> if (!pathString) {
<GeoLayer return null;
countries={countries} }
countryNames={countryNames}
selectedCountry={selectedCountry} return (
onSelectCountry={onSelectCountry} <g key={String(rawId)}>
thresholdLow={thresholdLow} <path
thresholdMedium={thresholdMedium} d={pathString}
thresholdHigh={thresholdHigh} role={cc ? "button" : undefined}
/> tabIndex={cc ? 0 : undefined}
</ZoomableGroup> aria-label={
</ComposableMap> 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);
}}
/>
</g>
);
})}
</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 }: { children?: React.ReactNode }) => <g>{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,16 +49,20 @@ describe("WorldMap", () => {
</FluentProvider>, </FluentProvider>,
); );
// Tooltip should not be present initially
expect(screen.queryByRole("tooltip")).toBeNull(); expect(screen.queryByRole("tooltip")).toBeNull();
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i }); const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
expect(countryButton).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Zoom in/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Zoom out/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Reset view/i })).toBeInTheDocument();
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 }); 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

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import * as configApi from "../../api/config";
import { useActionConfig } from "../useActionConfig";
vi.mock("../../api/config");
describe("useActionConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(configApi.fetchAction).mockResolvedValue({
name: "iptables",
filename: "iptables.conf",
source_file: "/etc/fail2ban/action.d/iptables.conf",
active: false,
used_by_jails: [],
before: null,
after: null,
actionstart: "",
actionstop: "",
actioncheck: "",
actionban: "",
actionunban: "",
actionflush: "",
definition_vars: {},
init_vars: {},
has_local_override: false,
});
vi.mocked(configApi.updateAction).mockResolvedValue(undefined);
});
it("calls fetchAction exactly once for stable name and rerenders", async () => {
const { rerender } = renderHook(
({ name }) => useActionConfig(name),
{ initialProps: { name: "iptables" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
// Rerender with the same action name; fetch should not be called again.
rerender({ name: "iptables" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
});
it("calls fetchAction again when name changes", async () => {
const { rerender } = renderHook(
({ name }) => useActionConfig(name),
{ initialProps: { name: "iptables" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
rerender({ name: "ssh" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchAction).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import * as configApi from "../../api/config";
import { useFilterConfig } from "../useFilterConfig";
vi.mock("../../api/config");
describe("useFilterConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(configApi.fetchParsedFilter).mockResolvedValue({
name: "sshd",
filename: "sshd.conf",
source_file: "/etc/fail2ban/filter.d/sshd.conf",
active: false,
used_by_jails: [],
before: null,
after: null,
variables: {},
prefregex: null,
failregex: [],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
has_local_override: false,
});
vi.mocked(configApi.updateParsedFilter).mockResolvedValue(undefined);
});
it("calls fetchParsedFilter only once for stable name", async () => {
const { rerender } = renderHook(
({ name }) => useFilterConfig(name),
{ initialProps: { name: "sshd" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
rerender({ name: "sshd" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
});
it("calls fetchParsedFilter again when name changes", async () => {
const { rerender } = renderHook(
({ name }) => useFilterConfig(name),
{ initialProps: { name: "sshd" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
rerender({ name: "apache-auth" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(2);
});
});

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed action config. * React hook for loading and updating a single parsed action config.
*/ */
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem"; import { useConfigItem } from "./useConfigItem";
import { fetchAction, updateAction } from "../api/config"; import { fetchAction, updateAction } from "../api/config";
import type { ActionConfig, ActionConfigUpdate } from "../types/config"; import type { ActionConfig, ActionConfigUpdate } from "../types/config";
@@ -23,12 +24,18 @@ export interface UseActionConfigResult {
* @param name - Action base name (e.g. ``"iptables"``). * @param name - Action base name (e.g. ``"iptables"``).
*/ */
export function useActionConfig(name: string): UseActionConfigResult { export function useActionConfig(name: string): UseActionConfigResult {
const fetchFn = useCallback(() => fetchAction(name), [name]);
const saveFn = useCallback(
(update: ActionConfigUpdate) => updateAction(name, update),
[name],
);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem< const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
ActionConfig, ActionConfig,
ActionConfigUpdate ActionConfigUpdate
>({ >({
fetchFn: () => fetchAction(name), fetchFn,
saveFn: (update) => updateAction(name, update), saveFn,
mergeOnSave: (prev, update) => mergeOnSave: (prev, update) =>
prev prev
? { ? {

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

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed filter config. * React hook for loading and updating a single parsed filter config.
*/ */
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem"; import { useConfigItem } from "./useConfigItem";
import { fetchParsedFilter, updateParsedFilter } from "../api/config"; import { fetchParsedFilter, updateParsedFilter } from "../api/config";
import type { FilterConfig, FilterConfigUpdate } from "../types/config"; import type { FilterConfig, FilterConfigUpdate } from "../types/config";
@@ -23,12 +24,18 @@ export interface UseFilterConfigResult {
* @param name - Filter base name (e.g. ``"sshd"``). * @param name - Filter base name (e.g. ``"sshd"``).
*/ */
export function useFilterConfig(name: string): UseFilterConfigResult { export function useFilterConfig(name: string): UseFilterConfigResult {
const fetchFn = useCallback(() => fetchParsedFilter(name), [name]);
const saveFn = useCallback(
(update: FilterConfigUpdate) => updateParsedFilter(name, update),
[name],
);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem< const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
FilterConfig, FilterConfig,
FilterConfigUpdate FilterConfigUpdate
>({ >({
fetchFn: () => fetchParsedFilter(name), fetchFn,
saveFn: (update) => updateParsedFilter(name, update), saveFn,
mergeOnSave: (prev, update) => mergeOnSave: (prev, update) =>
prev prev
? { ? {

View File

@@ -2,6 +2,7 @@
* React hook for loading and updating a single parsed jail.d config file. * React hook for loading and updating a single parsed jail.d config file.
*/ */
import { useCallback } from "react";
import { useConfigItem } from "./useConfigItem"; import { useConfigItem } from "./useConfigItem";
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config"; import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config"; import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
@@ -21,12 +22,18 @@ export interface UseJailFileConfigResult {
* @param filename - Filename including extension (e.g. ``"sshd.conf"``). * @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/ */
export function useJailFileConfig(filename: string): UseJailFileConfigResult { export function useJailFileConfig(filename: string): UseJailFileConfigResult {
const fetchFn = useCallback(() => fetchParsedJailFile(filename), [filename]);
const saveFn = useCallback(
(update: JailFileConfigUpdate) => updateParsedJailFile(filename, update),
[filename],
);
const { data, loading, error, refresh, save } = useConfigItem< const { data, loading, error, refresh, save } = useConfigItem<
JailFileConfig, JailFileConfig,
JailFileConfigUpdate JailFileConfigUpdate
>({ >({
fetchFn: () => fetchParsedJailFile(filename), fetchFn,
saveFn: (update) => updateParsedJailFile(filename, update), saveFn,
mergeOnSave: (prev, update) => mergeOnSave: (prev, update) =>
update.jails != null && prev update.jails != null && prev
? { ...prev, jails: { ...prev.jails, ...update.jails } } ? { ...prev, jails: { ...prev.jails, ...update.jails } }

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();
@@ -97,3 +98,21 @@ export function useMapData(
refresh: load, refresh: load,
}; };
} }
/**
* Test helper: returns arguments most recently used to call `useMapData`.
*
* This helper is only intended for test use with a mock implementation.
*/
export function getLastArgs(): { range: string; origin: string } {
throw new Error("getLastArgs is only available in tests with a mocked useMapData");
}
/**
* Test helper: mutates mocked map data state.
*
* This helper is only intended for test use with a mock implementation.
*/
export function setMapData(_: Partial<UseMapDataResult>): void {
throw new Error("setMapData is only available in tests with a mocked useMapData");
}

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 */}
{/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */}
<DashboardFilterBar <div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
timeRange={timeRange} <DashboardFilterBar
onTimeRangeChange={setTimeRange} timeRange={timeRange}
originFilter={originFilter} onTimeRangeChange={setTimeRange}
onOriginFilterChange={setOriginFilter} originFilter={originFilter}
/> 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

@@ -6,7 +6,7 @@
* Rows with repeatedly-banned IPs are highlighted in amber. * Rows with repeatedly-banned IPs are highlighted in amber.
*/ */
import { useCallback, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -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,
@@ -136,6 +130,25 @@ const useStyles = makeStyles({
}, },
}); });
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function areHistoryQueriesEqual(
a: HistoryQuery,
b: HistoryQuery,
): boolean {
return (
a.range === b.range &&
a.origin === b.origin &&
a.jail === b.jail &&
a.ip === b.ip &&
a.source === b.source &&
a.page === b.page &&
a.page_size === b.page_size
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Column definitions for the main history table // Column definitions for the main history table
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -374,11 +387,12 @@ export function HistoryPage(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
// 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,
}); });
@@ -388,15 +402,24 @@ export function HistoryPage(): React.JSX.Element {
const { items, total, page, loading, error, setPage, refresh } = const { items, total, page, loading, error, setPage, refresh } =
useHistory(appliedQuery); useHistory(appliedQuery);
const applyFilters = useCallback((): void => { useEffect((): void => {
setAppliedQuery({ const nextQuery: HistoryQuery = {
range: range, range,
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_size: PAGE_SIZE, page_size: PAGE_SIZE,
}); };
}, [range, originFilter, jailFilter, ipFilter]);
if (areHistoryQueriesEqual(nextQuery, appliedQuery)) {
return;
}
setPage(1);
setAppliedQuery(nextQuery);
}, [range, originFilter, jailFilter, ipFilter, setPage, appliedQuery]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
@@ -456,71 +479,17 @@ export function HistoryPage(): React.JSX.Element {
onOriginFilterChange={(value) => { onOriginFilterChange={(value) => {
setOriginFilter(value); setOriginFilter(value);
}} }}
/> jail={jailFilter}
onJailChange={(value) => {
<div className={styles.filterLabel}> setJailFilter(value);
<Text size={200}>Jail</Text>
<Input
placeholder="e.g. sshd"
value={jailFilter}
onChange={(_ev, data): void => {
setJailFilter(data.value);
}}
size="small"
/>
</div>
<div className={styles.filterLabel}>
<Text size={200}>IP Address</Text>
<Input
placeholder="e.g. 192.168"
value={ipFilter}
onChange={(_ev, data): void => {
setIpFilter(data.value);
}}
size="small"
onKeyDown={(e): void => {
if (e.key === "Enter") applyFilters();
}}
/>
</div>
<Button appearance="primary" size="small" onClick={applyFilters}>
Apply
</Button>
<Button
appearance="subtle"
size="small"
onClick={(): void => {
setRange("24h");
setOriginFilter("all");
setJailFilter("");
setIpFilter("");
setAppliedQuery({ page_size: PAGE_SIZE });
}} }}
> ip={ipFilter}
Clear onIpChange={(value) => {
</Button> setIpFilter(value);
}}
/>
</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

@@ -25,7 +25,12 @@ import {
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons"; import {
ArrowCounterclockwiseRegular,
ChevronLeftRegular,
ChevronRightRegular,
DismissRegular,
} from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { WorldMap } from "../components/WorldMap"; import { WorldMap } from "../components/WorldMap";
import { useMapData } from "../hooks/useMapData"; import { useMapData } from "../hooks/useMapData";
@@ -68,6 +73,15 @@ const useStyles = makeStyles({
borderRadius: tokens.borderRadiusMedium, borderRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorNeutralBackground2, backgroundColor: tokens.colorNeutralBackground2,
}, },
pagination: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
backgroundColor: tokens.colorNeutralBackground2,
},
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -79,9 +93,15 @@ export function MapPage(): React.JSX.Element {
const [range, setRange] = useState<TimeRange>("24h"); const [range, setRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all"); const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [selectedCountry, setSelectedCountry] = useState<string | null>(null); const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(100);
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,
@@ -99,6 +119,10 @@ export function MapPage(): React.JSX.Element {
} }
}, [mapThresholdError]); }, [mapThresholdError]);
useEffect(() => {
setPage(1);
}, [range, originFilter, selectedCountry, bans, pageSize]);
/** Bans visible in the companion table (filtered by selected country). */ /** Bans visible in the companion table (filtered by selected country). */
const visibleBans = useMemo(() => { const visibleBans = useMemo(() => {
if (!selectedCountry) return bans; if (!selectedCountry) return bans;
@@ -109,6 +133,15 @@ export function MapPage(): React.JSX.Element {
? (countryNames[selectedCountry] ?? selectedCountry) ? (countryNames[selectedCountry] ?? selectedCountry)
: null; : null;
const totalPages = Math.max(1, Math.ceil(visibleBans.length / pageSize));
const hasPrev = page > 1;
const hasNext = page < totalPages;
const pageBans = useMemo(() => {
const start = (page - 1) * pageSize;
return visibleBans.slice(start, start + pageSize);
}, [visibleBans, page, pageSize]);
return ( return (
<div className={styles.root}> <div className={styles.root}>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
@@ -235,7 +268,7 @@ export function MapPage(): React.JSX.Element {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
visibleBans.map((ban) => ( pageBans.map((ban) => (
<TableRow key={`${ban.ip}-${ban.banned_at}`}> <TableRow key={`${ban.ip}-${ban.banned_at}`}>
<TableCell> <TableCell>
<TableCellLayout>{ban.ip}</TableCellLayout> <TableCellLayout>{ban.ip}</TableCellLayout>
@@ -282,6 +315,53 @@ export function MapPage(): React.JSX.Element {
)} )}
</TableBody> </TableBody>
</Table> </Table>
<div className={styles.pagination}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Showing {pageBans.length} of {visibleBans.length} filtered ban{visibleBans.length !== 1 ? "s" : ""}
{" · "}Page {page} of {totalPages}
</Text>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Page size
</Text>
<select
aria-label="Page size"
value={pageSize}
onChange={(event): void => {
setPageSize(Number(event.target.value));
}}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
</div>
<div style={{ display: "flex", gap: tokens.spacingHorizontalXS }}>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
disabled={!hasPrev}
onClick={(): void => {
setPage(page - 1);
}}
aria-label="Previous page"
/>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
disabled={!hasNext}
onClick={(): void => {
setPage(page + 1);
}}
aria-label="Next page"
/>
</div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { HistoryPage } from "../HistoryPage";
let lastQuery: Record<string, unknown> | null = null; let lastQuery: Record<string, unknown> | null = null;
const mockUseHistory = vi.fn((query: Record<string, unknown>) => { const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
console.log("mockUseHistory called", query);
lastQuery = query; lastQuery = query;
return { return {
items: [], items: [],
@@ -18,16 +18,16 @@ const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
}; };
}); });
vi.mock("../hooks/useHistory", () => ({ vi.mock("../../hooks/useHistory", () => ({
useHistory: (query: Record<string, unknown>) => mockUseHistory(query), useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }), useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
})); }));
vi.mock("../components/WorldMap", () => ({ vi.mock("../../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />, WorldMap: () => <div data-testid="world-map" />,
})); }));
vi.mock("../api/config", () => ({ vi.mock("../../api/config", () => ({
fetchMapColorThresholds: async () => ({ fetchMapColorThresholds: async () => ({
threshold_low: 10, threshold_low: 10,
threshold_medium: 50, threshold_medium: 50,
@@ -35,8 +35,10 @@ vi.mock("../api/config", () => ({
}), }),
})); }));
import { HistoryPage } from "../HistoryPage";
describe("HistoryPage", () => { describe("HistoryPage", () => {
it("renders DashboardFilterBar and applies origin+range filters", async () => { it("auto-applies filters on change and hides apply/clear actions", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
@@ -45,14 +47,31 @@ describe("HistoryPage", () => {
</FluentProvider>, </FluentProvider>,
); );
// Initial load should include the default query. // Initial load should include the auto-applied default query.
expect(lastQuery).toEqual({ page_size: 50 }); await waitFor(() => {
expect(lastQuery).toEqual({
range: "7d",
source: "archive",
origin: undefined,
jail: undefined,
ip: undefined,
page: 1,
page_size: 50,
});
});
// Change the time-range and origin filter, then apply. expect(screen.queryByRole("button", { name: /apply/i })).toBeNull();
expect(screen.queryByRole("button", { name: /clear/i })).toBeNull();
// Time-range and origin updates should be applied automatically.
await user.click(screen.getByRole("button", { name: /Last 7 days/i })); await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
await user.click(screen.getByRole("button", { name: /Blocklist/i })); await waitFor(() => {
await user.click(screen.getByRole("button", { name: /Apply/i })); expect(lastQuery).toMatchObject({ range: "7d" });
});
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" }); await user.click(screen.getByRole("button", { name: /Blocklist/i }));
await waitFor(() => {
expect(lastQuery).toMatchObject({ origin: "blocklist" });
});
}); });
}); });

View File

@@ -2,42 +2,43 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { getLastArgs, setMapData } from "../../hooks/useMapData";
import { MapPage } from "../MapPage"; import { MapPage } from "../MapPage";
const mockFetchMapColorThresholds = vi.fn(async () => ({ vi.mock("../../hooks/useMapData", () => {
threshold_low: 10, let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
threshold_medium: 50, let dataState = {
threshold_high: 100,
}));
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
const mockUseMapData = vi.fn((range: string, origin: string) => {
lastArgs = { range, origin };
return {
countries: {}, countries: {},
countryNames: {}, countryNames: {},
bans: [], bans: [],
total: 0, total: 0,
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: () => {},
};
return {
useMapData: (range: string, origin: string) => {
lastArgs = { range, origin };
return { ...dataState };
},
setMapData: (newState: Partial<typeof dataState>) => {
dataState = { ...dataState, ...newState };
},
getLastArgs: () => lastArgs,
}; };
}); });
vi.mock("../hooks/useMapData", () => ({ vi.mock("../../api/config", () => ({
useMapData: (range: string, origin: string) => mockUseMapData(range, origin), fetchMapColorThresholds: vi.fn(async () => ({
threshold_low: 10,
threshold_medium: 50,
threshold_high: 100,
})),
})); }));
vi.mock("../api/config", async () => ({ vi.mock("../../components/WorldMap", () => ({
fetchMapColorThresholds: mockFetchMapColorThresholds, WorldMap: () => <div data-testid="world-map" />,
}));
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
vi.mock("../components/WorldMap", () => ({
WorldMap: (props: unknown) => {
mockWorldMap(props);
return <div data-testid="world-map" />;
},
})); }));
describe("MapPage", () => { describe("MapPage", () => {
@@ -51,17 +52,63 @@ describe("MapPage", () => {
); );
// Initial load should call useMapData with default filters. // Initial load should call useMapData with default filters.
expect(lastArgs).toEqual({ range: "24h", origin: "all" }); expect(getLastArgs()).toEqual({ range: "24h", origin: "all" });
// Map should receive country names from the hook so tooltips can show human-readable labels.
expect(mockWorldMap).toHaveBeenCalled();
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
expect(firstCallArgs).toMatchObject({ countryNames: {} });
await user.click(screen.getByRole("button", { name: /Last 7 days/i })); await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
expect(lastArgs.range).toBe("7d"); expect(getLastArgs().range).toBe("7d");
await user.click(screen.getByRole("button", { name: /Blocklist/i })); await user.click(screen.getByRole("button", { name: /Blocklist/i }));
expect(lastArgs.origin).toBe("blocklist"); expect(getLastArgs().origin).toBe("blocklist");
});
it("supports pagination with 100 items per page and reset on filter changes", async () => {
const user = userEvent.setup();
const bans: import("../../types/map").MapBanItem[] = Array.from({ length: 120 }, (_, index) => ({
ip: `192.0.2.${index}`,
jail: "ssh",
banned_at: new Date(Date.now() - index * 1000).toISOString(),
service: null,
country_code: "US",
country_name: "United States",
asn: null,
org: null,
ban_count: 1,
origin: "selfblock",
}));
setMapData({
countries: { US: 120 },
countryNames: { US: "United States" },
bans,
total: 120,
loading: false,
error: null,
});
render(
<FluentProvider theme={webLightTheme}>
<MapPage />
</FluentProvider>,
);
expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument();
expect(screen.getByText(/Showing 100 of 120 filtered bans/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Next page/i }));
expect(await screen.findByText(/Page 2 of 2/i)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Previous page/i }));
expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument();
// Page size selector should adjust pagination
await user.selectOptions(screen.getByRole("combobox", { name: /Page size/i }), "25");
expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument();
expect(screen.getByText(/Showing 25 of 120 filtered bans/i)).toBeInTheDocument();
// Changing filter keeps page reset to 1
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
expect(getLastArgs().origin).toBe("blocklist");
expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument();
}); });
}); });

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",