Compare commits
31 Commits
v0.9.14
...
96f75db75f
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f75db75f | |||
| 554c75247f | |||
| 6e2abe9d97 | |||
| 15d53a8e96 | |||
| acdb0e1f03 | |||
| f1e3d4c4c9 | |||
| c51858ec71 | |||
| c03a5c1cbc | |||
| eb983799cd | |||
| d3f564d66f | |||
| bbd57c808b | |||
| ffaa14f864 | |||
| 7d09b78437 | |||
| 8e2bb5d3fb | |||
| bfe0daf754 | |||
| 13823b1182 | |||
| 7967191ccd | |||
| 470c29443c | |||
| 6f15e1fa24 | |||
| 487cb171f2 | |||
| 7789353690 | |||
| ccfcbc82c5 | |||
| 7626c9cb60 | |||
| ac4fd967aa | |||
| 9f05da2d4d | |||
| 876af46955 | |||
| 0d4a2a3311 | |||
| f555b1b0a2 | |||
| a30b92471a | |||
| 9e43282bbc | |||
| 2ea4a8304f |
@@ -1 +1 @@
|
||||
v0.9.14
|
||||
v0.9.19
|
||||
|
||||
@@ -78,6 +78,11 @@ Chains steps 1–3 automatically with appropriate sleep intervals.
|
||||
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
||||
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
||||
|
||||
BanGUI also extends fail2ban history retention for archive backfill. In
|
||||
the development config `fail2ban/fail2ban.conf` the database purge age is
|
||||
set to `648000` seconds (7.5 days) so the first archive sync can recover a
|
||||
full 7-day window before fail2ban purges old rows.
|
||||
|
||||
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
|
||||
|
||||
```ini
|
||||
|
||||
@@ -68,6 +68,15 @@ FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -52,6 +52,8 @@ The main landing page after login. Shows recent ban activity at a glance.
|
||||
- Last 7 days (week)
|
||||
- Last 30 days (month)
|
||||
- Last 365 days (year)
|
||||
- **Data source selection:** The "Last 24 hours" preset queries fail2ban's live database directly for real-time accuracy. All longer presets (7 days, 30 days, 365 days) query the BanGUI long-term archive, because fail2ban's own database only retains the last 24 hours by default.
|
||||
- A **data-source badge** next to the time-range selector indicates whether the current view is showing **Live (fail2ban DB)** or **Archive (BanGUI DB)** data.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,14 +72,21 @@ A geographical overview of ban activity.
|
||||
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend)
|
||||
- The color threshold values are configurable through the application settings
|
||||
- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner.
|
||||
- For every country that has bans, the total count is displayed centred inside that country's borders in the selected time range.
|
||||
- Countries with zero banned IPs show no number and no label — they remain blank and transparent.
|
||||
- Clicking a country filters the companion table below to show only bans from that country.
|
||||
- For every country that has bans, the total count is shown only in the country tooltip, not rendered on the map itself.
|
||||
- Countries with zero banned IPs show no tooltip and remain blank and transparent.
|
||||
- Clicking a country filters the companion table below to show only bans from that country. When a country is selected the server returns the **complete** list of bans for that country in the chosen time window — the default 200-row companion cap is lifted for filtered queries. Clicking the same country again or using the "Clear filter" button reverts to the standard unfiltered view.
|
||||
- Time-range selector with the same quick presets:
|
||||
- Last 24 hours
|
||||
- Last 7 days
|
||||
- Last 30 days
|
||||
- Last 365 days
|
||||
- **Data source selection:** Same rule as the Dashboard — "Last 24 hours" uses the live fail2ban database; all other ranges use the BanGUI archive.
|
||||
- A **data-source badge** is displayed alongside the time-range selector indicating **Live (fail2ban DB)** or **Archive (BanGUI DB)**.
|
||||
|
||||
### Companion Table
|
||||
|
||||
- The column header row is always visible at the top of the scrollable table area (sticky positioning) so column labels remain readable regardless of scroll position.
|
||||
- The pagination / page-size bar is always visible at the bottom of the scrollable table area (sticky positioning) so the user can navigate pages without scrolling back down.
|
||||
|
||||
---
|
||||
|
||||
@@ -245,13 +254,15 @@ A page to inspect and modify the fail2ban configuration without leaving the web
|
||||
|
||||
## 7. Ban History
|
||||
|
||||
A view for exploring historical ban data stored in the fail2ban database.
|
||||
A view for exploring historical ban data stored in the BanGUI long-term archive.
|
||||
|
||||
### History Table
|
||||
|
||||
- Browse all past bans across all jails, not just the currently active ones.
|
||||
- **Columns:** Time of ban, IP address, jail, ban duration, ban count (how many times this IP was banned), country.
|
||||
- Filter by jail, by IP address, or by time range.
|
||||
- The default time range on first load is **Last 7 days** and the data source is always the **BanGUI archive**, ensuring the full retention window is visible regardless of fail2ban's `dbpurgeage` setting.
|
||||
- A **data-source badge** is displayed indicating **Archive (BanGUI DB)**.
|
||||
- See at a glance which IPs are repeat offenders (high ban count).
|
||||
|
||||
### Per-IP History
|
||||
@@ -259,6 +270,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.
|
||||
- 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
|
||||
|
||||
@@ -7,3 +7,67 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
||||
---
|
||||
|
||||
## Open Issues
|
||||
|
||||
---
|
||||
|
||||
### TASK-001 — WorldMap: filter companion table by selected country (server-side)
|
||||
|
||||
**Status:** Done
|
||||
**Priority:** Medium
|
||||
**Domain:** Full-stack (backend + frontend)
|
||||
**References:** `Docs/Features.md §4`, `Docs/Web-Development.md`
|
||||
|
||||
#### Background
|
||||
|
||||
The `GET /api/dashboard/bans/by-country` endpoint always returns the **200 most recent** ban rows in `bans` (constant `_MAX_COMPANION_BANS = 200` in `backend/app/services/ban_service.py`). `MapPage.tsx` stores a `selectedCountry` state and filters the returned rows client-side via `visibleBans`. This means the companion table can only show the fraction of a country's bans that fall within the global top-200 window. If the selected time range has, say, 1 500 bans and 300 are from China, but China's bans are not all in the top 200 overall, the table will silently display fewer than 300 rows.
|
||||
|
||||
When a country is selected the companion table **must** return the complete set of bans for that country so the user sees an accurate picture.
|
||||
|
||||
#### Desired behaviour
|
||||
|
||||
- No country selected → companion table shows the 200 most recent bans across all countries (existing behaviour, no change).
|
||||
- Country selected → the server returns **all** ban entries for that country in the selected time window; no client-side row-count cap applies.
|
||||
- Deselecting a country (clicking the same country again, or the "Clear filter" button) reverts to the default 200-row unfiltered view.
|
||||
- The existing `visibleBans` client-side filter in `MapPage.tsx` can remain as a defensive guard but must not be the only filter.
|
||||
|
||||
#### Implementation steps
|
||||
|
||||
1. **Backend — router** (`backend/app/routers/dashboard.py`)
|
||||
- Add `country_code: str | None = Query(default=None, description="ISO alpha-2 country code to filter companion rows.")` to `get_bans_by_country`.
|
||||
- Pass it to `ban_service.bans_by_country(..., country_code=country_code)`.
|
||||
|
||||
2. **Backend — service** (`backend/app/services/ban_service.py`)
|
||||
- Add `country_code: str | None = None` keyword argument to `bans_by_country`.
|
||||
- After `geo_map` is built (existing geo-resolution step), collect IPs whose resolved country matches `country_code`.
|
||||
- For the **fail2ban source**: call `fail2ban_db_repo.get_currently_banned` with `ip_filter=matched_ips` and no `limit` (remove the `_MAX_COMPANION_BANS` cap for filtered queries).
|
||||
- For the **archive source**: filter `all_rows` to those whose IP is in `matched_ips` and return all of them (skip the `page_size=_MAX_COMPANION_BANS` call).
|
||||
- When `country_code` is `None`, behaviour is identical to today.
|
||||
|
||||
3. **Backend — repository** (`backend/app/repositories/fail2ban_db_repo.py`)
|
||||
- Add `ip_filter: list[str] | None = None` to `get_currently_banned`.
|
||||
- When provided and non-empty, append `AND ip IN ({placeholders})` to the SQL `WHERE` clause, parameterised safely (never interpolated as a string).
|
||||
|
||||
4. **Backend — repository (archive)** (`backend/app/repositories/history_archive_repo.py`)
|
||||
- Similarly add optional `ip_filter` to the archive companion-rows query used from `bans_by_country`.
|
||||
|
||||
5. **Frontend — API client** (`frontend/src/api/map.ts`)
|
||||
- Add optional `countryCode?: string` parameter to `fetchBansByCountry`.
|
||||
- When set, append `country_code=<value>` to the query string.
|
||||
|
||||
6. **Frontend — hook** (`frontend/src/hooks/useMapData.ts`)
|
||||
- Add `countryCode?: string` to the function signature.
|
||||
- Include it in the `useCallback` dependency array and pass it to `fetchBansByCountry`.
|
||||
|
||||
7. **Frontend — page** (`frontend/src/pages/MapPage.tsx`)
|
||||
- Pass `selectedCountry ?? undefined` as `countryCode` to `useMapData`.
|
||||
- The hook's effect will re-fetch automatically when `selectedCountry` changes; the existing `useEffect` that resets `page` to 1 already covers this.
|
||||
|
||||
#### Testing guidance
|
||||
|
||||
- Select a country that has > 200 bans in the chosen time window; confirm the companion table shows more than the previous cap would allow.
|
||||
- With no country selected, confirm only 200 rows are returned (no regression).
|
||||
- Deselect the country; confirm the unfiltered 200-row view is restored.
|
||||
- Test with the archive source as well as the fail2ban live source.
|
||||
- Verify the `ip_filter` SQL clause is parameterised and cannot be injected.
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +210,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho
|
||||
|
||||
| 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`. |
|
||||
| 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. |
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
_SCHEMA_STATEMENTS: list[str] = [
|
||||
_CREATE_SETTINGS,
|
||||
@@ -83,6 +97,7 @@ _SCHEMA_STATEMENTS: list[str] = [
|
||||
_CREATE_BLOCKLIST_SOURCES,
|
||||
_CREATE_IMPORT_LOG,
|
||||
_CREATE_GEO_CACHE,
|
||||
_CREATE_HISTORY_ARCHIVE,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ from app.routers import (
|
||||
server,
|
||||
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.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 ---
|
||||
geo_re_resolve.register(app)
|
||||
|
||||
# --- Periodic history sync from fail2ban into BanGUI archive ---
|
||||
history_sync.register(app)
|
||||
|
||||
log.info("bangui_started")
|
||||
|
||||
try:
|
||||
|
||||
@@ -56,3 +56,7 @@ class ServerSettingsResponse(BaseModel):
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
settings: ServerSettings
|
||||
warnings: dict[str, bool] = Field(
|
||||
default_factory=dict,
|
||||
description="Warnings highlighting potentially unsafe settings.",
|
||||
)
|
||||
|
||||
@@ -126,6 +126,7 @@ async def get_currently_banned(
|
||||
since: int,
|
||||
origin: BanOrigin | None = None,
|
||||
*,
|
||||
ip_filter: list[str] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> tuple[list[BanRecord], int]:
|
||||
@@ -135,6 +136,7 @@ async def get_currently_banned(
|
||||
db_path: File path to the fail2ban SQLite database.
|
||||
since: Unix timestamp to filter bans newer than or equal to.
|
||||
origin: Optional origin filter.
|
||||
ip_filter: Optional list of IP addresses to restrict the result to.
|
||||
limit: Optional maximum number of rows to return.
|
||||
offset: Optional offset for pagination.
|
||||
|
||||
@@ -142,14 +144,21 @@ async def get_currently_banned(
|
||||
A ``(records, total)`` tuple.
|
||||
"""
|
||||
|
||||
if ip_filter is not None and len(ip_filter) == 0:
|
||||
return [], 0
|
||||
|
||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||
ip_filter_clause = ""
|
||||
if ip_filter is not None:
|
||||
placeholder = ", ".join("?" for _ in ip_filter)
|
||||
ip_filter_clause = f" AND ip IN ({placeholder})"
|
||||
|
||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
|
||||
(since, *origin_params),
|
||||
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause + ip_filter_clause,
|
||||
(since, *origin_params, *(ip_filter or [])),
|
||||
) as cur:
|
||||
count_row = await cur.fetchone()
|
||||
total: int = int(count_row[0]) if count_row else 0
|
||||
@@ -157,9 +166,9 @@ async def get_currently_banned(
|
||||
query = (
|
||||
"SELECT jail, ip, timeofban, bancount, data "
|
||||
"FROM bans "
|
||||
"WHERE timeofban >= ?" + origin_clause + " ORDER BY timeofban DESC"
|
||||
"WHERE timeofban >= ?" + origin_clause + ip_filter_clause + " ORDER BY timeofban DESC"
|
||||
)
|
||||
params: list[object] = [since, *origin_params]
|
||||
params: list[object] = [since, *origin_params, *(ip_filter or [])]
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
156
backend/app/repositories/history_archive_repo.py
Normal file
156
backend/app/repositories/history_archive_repo.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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 | list[str] | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
action: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Return a paginated archived history result set."""
|
||||
if isinstance(ip_filter, list) and len(ip_filter) == 0:
|
||||
return [], 0
|
||||
|
||||
wheres: list[str] = []
|
||||
params: list[object] = []
|
||||
|
||||
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:
|
||||
if isinstance(ip_filter, list):
|
||||
placeholder = ", ".join("?" for _ in ip_filter)
|
||||
wheres.append(f"ip IN ({placeholder})")
|
||||
params.extend(ip_filter)
|
||||
else:
|
||||
wheres.append("ip LIKE ?")
|
||||
params.append(f"{ip_filter}%")
|
||||
|
||||
if origin == "blocklist":
|
||||
wheres.append("jail = ?")
|
||||
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 | list[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
|
||||
@@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
@@ -83,6 +83,10 @@ async def get_dashboard_bans(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
|
||||
origin: BanOrigin | None = Query(
|
||||
@@ -117,10 +121,11 @@ async def get_dashboard_bans(
|
||||
return await ban_service.list_bans(
|
||||
socket_path,
|
||||
range,
|
||||
source=source,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
http_session=http_session,
|
||||
app_db=None,
|
||||
app_db=request.app.state.db,
|
||||
geo_batch_lookup=geo_service.lookup_batch,
|
||||
origin=origin,
|
||||
)
|
||||
@@ -135,10 +140,18 @@ async def get_bans_by_country(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
),
|
||||
country_code: str | None = Query(
|
||||
default=None,
|
||||
description="ISO alpha-2 country code to filter companion rows.",
|
||||
),
|
||||
) -> BansByCountryResponse:
|
||||
"""Return ban counts aggregated by ISO country code.
|
||||
|
||||
@@ -164,11 +177,13 @@ async def get_bans_by_country(
|
||||
return await ban_service.bans_by_country(
|
||||
socket_path,
|
||||
range,
|
||||
source=source,
|
||||
http_session=http_session,
|
||||
geo_cache_lookup=geo_service.lookup_cached_only,
|
||||
geo_batch_lookup=geo_service.lookup_batch,
|
||||
app_db=None,
|
||||
app_db=request.app.state.db,
|
||||
origin=origin,
|
||||
country_code=country_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -181,6 +196,10 @@ async def get_ban_trend(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
@@ -212,7 +231,13 @@ async def get_ban_trend(
|
||||
"""
|
||||
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(
|
||||
@@ -224,6 +249,10 @@ async def get_bans_by_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
@@ -248,4 +277,10 @@ async def get_bans_by_jail(
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ Routes
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
@@ -56,6 +56,10 @@ async def get_history(
|
||||
default=None,
|
||||
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_size: int = Query(
|
||||
default=_DEFAULT_PAGE_SIZE,
|
||||
@@ -94,9 +98,47 @@ async def get_history(
|
||||
jail=jail,
|
||||
ip_filter=ip,
|
||||
origin=origin,
|
||||
source=source,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ async def list_bans(
|
||||
socket_path: str,
|
||||
range_: TimeRange,
|
||||
*,
|
||||
source: str = "fail2ban",
|
||||
page: int = 1,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
http_session: aiohttp.ClientSession | None = None,
|
||||
@@ -160,24 +161,41 @@ async def list_bans(
|
||||
since: int = _since_unix(range_)
|
||||
effective_page_size: int = min(page_size, _MAX_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)
|
||||
log.info(
|
||||
"ban_service_list_bans",
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
range=range_,
|
||||
origin=origin,
|
||||
)
|
||||
if source not in ("fail2ban", "archive"):
|
||||
raise ValueError(f"Unsupported source: {source!r}")
|
||||
|
||||
rows, total = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
limit=effective_page_size,
|
||||
offset=offset,
|
||||
)
|
||||
if source == "archive":
|
||||
if app_db is None:
|
||||
raise ValueError("app_db must be provided when source is 'archive'")
|
||||
|
||||
from app.repositories.history_archive_repo import get_archived_history
|
||||
|
||||
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.
|
||||
# This avoids hitting the 45 req/min single-IP rate limit when the
|
||||
@@ -192,11 +210,19 @@ async def list_bans(
|
||||
|
||||
items: list[DashboardBanItem] = []
|
||||
for row in rows:
|
||||
jail: str = row.jail
|
||||
ip: str = row.ip
|
||||
banned_at: str = ts_to_iso(row.timeofban)
|
||||
ban_count: int = row.bancount
|
||||
matches, _ = parse_data_json(row.data)
|
||||
if source == "archive":
|
||||
jail = str(row["jail"])
|
||||
ip = str(row["ip"])
|
||||
banned_at = ts_to_iso(int(row["timeofban"]))
|
||||
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
|
||||
|
||||
country_code: str | None = None
|
||||
@@ -256,12 +282,15 @@ _MAX_COMPANION_BANS: int = 200
|
||||
async def bans_by_country(
|
||||
socket_path: str,
|
||||
range_: TimeRange,
|
||||
*,
|
||||
source: str = "fail2ban",
|
||||
http_session: aiohttp.ClientSession | None = None,
|
||||
geo_cache_lookup: GeoCacheLookup | None = None,
|
||||
geo_batch_lookup: GeoBatchLookup | None = None,
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
app_db: aiosqlite.Connection | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
country_code: str | None = None,
|
||||
) -> BansByCountryResponse:
|
||||
"""Aggregate ban counts per country for the selected time window.
|
||||
|
||||
@@ -300,41 +329,62 @@ async def bans_by_country(
|
||||
"""
|
||||
|
||||
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.
|
||||
# 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,
|
||||
)
|
||||
if source not in ("fail2ban", "archive"):
|
||||
raise ValueError(f"Unsupported source: {source!r}")
|
||||
|
||||
agg_rows = await fail2ban_db_repo.get_ban_event_counts(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
)
|
||||
if source == "archive":
|
||||
if app_db is None:
|
||||
raise ValueError("app_db must be provided when source is 'archive'")
|
||||
|
||||
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
limit=_MAX_COMPANION_BANS,
|
||||
offset=0,
|
||||
)
|
||||
from app.repositories.history_archive_repo import (
|
||||
get_all_archived_history,
|
||||
get_archived_history,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
unique_ips = [r.ip for r in agg_rows]
|
||||
geo_map: dict[str, GeoInfo] = {}
|
||||
|
||||
if http_session is not None and unique_ips and geo_cache_lookup is not None:
|
||||
@@ -367,16 +417,80 @@ async def bans_by_country(
|
||||
results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips))
|
||||
geo_map = {ip: geo for ip, geo in results if geo is not None}
|
||||
|
||||
companion_rows: list[dict[str, object] | fail2ban_db_repo.BanRecord]
|
||||
if country_code is None:
|
||||
if source == "archive":
|
||||
companion_rows, _ = await get_archived_history(
|
||||
db=app_db,
|
||||
since=since,
|
||||
origin=origin,
|
||||
action="ban",
|
||||
page=1,
|
||||
page_size=_MAX_COMPANION_BANS,
|
||||
)
|
||||
else:
|
||||
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
limit=_MAX_COMPANION_BANS,
|
||||
offset=0,
|
||||
)
|
||||
else:
|
||||
matched_ips = [
|
||||
ip
|
||||
for ip, geo in geo_map.items()
|
||||
if geo is not None and geo.country_code == country_code
|
||||
]
|
||||
|
||||
if source == "archive":
|
||||
if matched_ips:
|
||||
companion_rows = await get_all_archived_history(
|
||||
db=app_db,
|
||||
since=since,
|
||||
origin=origin,
|
||||
action="ban",
|
||||
ip_filter=matched_ips,
|
||||
)
|
||||
else:
|
||||
companion_rows = []
|
||||
else:
|
||||
if matched_ips:
|
||||
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
ip_filter=matched_ips,
|
||||
)
|
||||
else:
|
||||
companion_rows = []
|
||||
|
||||
# Build country aggregation from the SQL-grouped rows.
|
||||
countries: dict[str, int] = {}
|
||||
country_names: dict[str, str] = {}
|
||||
|
||||
for agg_row in agg_rows:
|
||||
ip: str = agg_row.ip
|
||||
if source == "archive":
|
||||
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)
|
||||
cc: str | None = geo.country_code if geo else None
|
||||
cn: str | None = geo.country_name if geo else None
|
||||
event_count: int = agg_row.event_count
|
||||
|
||||
if cc:
|
||||
countries[cc] = countries.get(cc, 0) + event_count
|
||||
@@ -386,26 +500,38 @@ async def bans_by_country(
|
||||
# Build companion table from recent rows (geo already cached from batch step).
|
||||
bans: list[DashboardBanItem] = []
|
||||
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)
|
||||
cc = geo.country_code if geo else None
|
||||
cn = geo.country_name if geo else None
|
||||
asn: str | None = geo.asn if geo else None
|
||||
org: str | None = geo.org if geo else None
|
||||
matches, _ = parse_data_json(companion_row.data)
|
||||
|
||||
bans.append(
|
||||
DashboardBanItem(
|
||||
ip=ip,
|
||||
jail=companion_row.jail,
|
||||
banned_at=ts_to_iso(companion_row.timeofban),
|
||||
service=matches[0] if matches else None,
|
||||
jail=jail,
|
||||
banned_at=banned_at,
|
||||
service=service,
|
||||
country_code=cc,
|
||||
country_name=cn,
|
||||
asn=asn,
|
||||
org=org,
|
||||
ban_count=companion_row.bancount,
|
||||
origin=_derive_origin(companion_row.jail),
|
||||
ban_count=ban_count,
|
||||
origin=_derive_origin(jail),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -426,6 +552,8 @@ async def ban_trend(
|
||||
socket_path: str,
|
||||
range_: TimeRange,
|
||||
*,
|
||||
source: str = "fail2ban",
|
||||
app_db: aiosqlite.Connection | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
) -> BanTrendResponse:
|
||||
"""Return ban counts aggregated into equal-width time buckets.
|
||||
@@ -457,26 +585,58 @@ async def ban_trend(
|
||||
since: int = _since_unix(range_)
|
||||
bucket_secs: int = BUCKET_SECONDS[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)
|
||||
log.info(
|
||||
"ban_service_ban_trend",
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
range=range_,
|
||||
origin=origin,
|
||||
bucket_secs=bucket_secs,
|
||||
num_buckets=num_buckets,
|
||||
)
|
||||
if source not in ("fail2ban", "archive"):
|
||||
raise ValueError(f"Unsupported source: {source!r}")
|
||||
|
||||
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,
|
||||
)
|
||||
if source == "archive":
|
||||
if app_db is None:
|
||||
raise ValueError("app_db must be provided when source is 'archive'")
|
||||
|
||||
from app.repositories.history_archive_repo import get_all_archived_history
|
||||
|
||||
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] = [
|
||||
BanTrendBucket(
|
||||
@@ -501,6 +661,8 @@ async def bans_by_jail(
|
||||
socket_path: str,
|
||||
range_: TimeRange,
|
||||
*,
|
||||
source: str = "fail2ban",
|
||||
app_db: aiosqlite.Connection | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
) -> BansByJailResponse:
|
||||
"""Return ban counts aggregated per jail for the selected time window.
|
||||
@@ -522,38 +684,75 @@ async def bans_by_jail(
|
||||
sorted descending and the total ban count.
|
||||
"""
|
||||
since: int = _since_unix(range_)
|
||||
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,
|
||||
)
|
||||
if source not in ("fail2ban", "archive"):
|
||||
raise ValueError(f"Unsupported source: {source!r}")
|
||||
|
||||
total, jail_counts = await fail2ban_db_repo.get_bans_by_jail(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
)
|
||||
if source == "archive":
|
||||
if app_db is None:
|
||||
raise ValueError("app_db must be provided when source is 'archive'")
|
||||
|
||||
# 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_,
|
||||
)
|
||||
from app.repositories.history_archive_repo import get_all_archived_history
|
||||
|
||||
all_rows = await get_all_archived_history(
|
||||
db=app_db,
|
||||
since=since,
|
||||
origin=origin,
|
||||
action="ban",
|
||||
)
|
||||
|
||||
jail_counter: dict[str, int] = {}
|
||||
for row in all_rows:
|
||||
jail_name = str(row["jail"])
|
||||
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(
|
||||
"ban_service_bans_by_jail_result",
|
||||
|
||||
@@ -351,8 +351,8 @@ async def update_jail_config(
|
||||
await _set("datepattern", update.date_pattern)
|
||||
if update.dns_mode is not None:
|
||||
await _set("usedns", update.dns_mode)
|
||||
if update.backend is not None:
|
||||
await _set("backend", update.backend)
|
||||
# backend is managed by fail2ban and cannot be changed at runtime by API.
|
||||
# This field is therefore ignored during updates.
|
||||
if update.log_encoding is not None:
|
||||
await _set("logencoding", update.log_encoding)
|
||||
if update.prefregex is not None:
|
||||
|
||||
@@ -16,6 +16,8 @@ from typing import TYPE_CHECKING
|
||||
import structlog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiosqlite
|
||||
|
||||
from app.models.geo import GeoEnricher
|
||||
|
||||
from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
|
||||
@@ -63,9 +65,11 @@ async def list_history(
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
source: str = "fail2ban",
|
||||
page: int = 1,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
db: aiosqlite.Connection | None = None,
|
||||
) -> HistoryListResponse:
|
||||
"""Return a paginated list of historical ban records with optional filters.
|
||||
|
||||
@@ -104,55 +108,111 @@ async def list_history(
|
||||
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] = []
|
||||
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)
|
||||
total: int
|
||||
|
||||
country_code: str | None = None
|
||||
country_name: str | None = None
|
||||
asn: str | None = None
|
||||
org: str | None = None
|
||||
if source == "archive":
|
||||
if db is None:
|
||||
raise ValueError("db must be provided when source is 'archive'")
|
||||
|
||||
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)
|
||||
from app.repositories.history_archive_repo import get_archived_history
|
||||
|
||||
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,
|
||||
)
|
||||
archived_rows, total = await get_archived_history(
|
||||
db=db,
|
||||
since=since,
|
||||
jail=jail,
|
||||
ip_filter=ip_filter,
|
||||
page=page,
|
||||
page_size=effective_page_size,
|
||||
)
|
||||
|
||||
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(
|
||||
items=items,
|
||||
total=total,
|
||||
|
||||
@@ -160,8 +160,12 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
||||
db_max_matches=db_max_matches,
|
||||
)
|
||||
|
||||
log.info("server_settings_fetched")
|
||||
return ServerSettingsResponse(settings=settings)
|
||||
warnings: dict[str, bool] = {
|
||||
"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:
|
||||
|
||||
109
backend/app/tasks/history_sync.py
Normal file
109
backend/app/tasks/history_sync.py
Normal 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)
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bangui-backend"
|
||||
version = "0.9.8"
|
||||
version = "0.9.18"
|
||||
description = "BanGUI backend — fail2ban web management interface"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -80,6 +80,32 @@ async def test_get_currently_banned_filters_and_pagination(tmp_path: Path) -> No
|
||||
assert records[0].ip == "3.3.3.3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_currently_banned_filters_by_ip_list(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "fail2ban.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await _create_bans_table(db)
|
||||
await db.executemany(
|
||||
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("jail1", "1.1.1.1", 10, 1, "{}"),
|
||||
("jail1", "2.2.2.2", 20, 1, "{}"),
|
||||
("jail1", "3.3.3.3", 30, 1, "{}"),
|
||||
],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
records, total = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=0,
|
||||
ip_filter=["2.2.2.2", "3.3.3.3"],
|
||||
)
|
||||
|
||||
assert total == 2
|
||||
assert len(records) == 2
|
||||
assert {record.ip for record in records} == {"2.2.2.2", "3.3.3.3"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ban_counts_by_bucket_ignores_out_of_range_buckets(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "fail2ban.db")
|
||||
|
||||
64
backend/tests/test_repositories/test_history_archive_repo.py
Normal file
64
backend/tests/test_repositories/test_history_archive_repo.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""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
|
||||
|
||||
rows, total = await get_archived_history(db, ip_filter=["2.2.2.2"])
|
||||
assert total == 1
|
||||
assert rows[0]["ip"] == "2.2.2.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_archived_history(app_db: str) -> None:
|
||||
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
|
||||
@@ -290,6 +290,17 @@ class TestDashboardBans:
|
||||
called_range = mock_list.call_args[0][1]
|
||||
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(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
@@ -417,6 +428,15 @@ class TestBansByCountry:
|
||||
called_range = mock_fn.call_args[0][1]
|
||||
assert called_range == "7d"
|
||||
|
||||
async def test_invalid_source_returns_422(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""An invalid source value returns HTTP 422."""
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/bans/by-country?source=invalid"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_empty_window_returns_empty_response(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
@@ -492,6 +512,29 @@ class TestDashboardBansOriginField:
|
||||
origins = {ban["origin"] for ban in bans}
|
||||
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_bans_by_country_country_code_forwarded(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""The ``country_code`` query parameter is forwarded to bans_by_country."""
|
||||
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
|
||||
with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn):
|
||||
await dashboard_client.get(
|
||||
"/api/dashboard/bans/by-country?country_code=DE"
|
||||
)
|
||||
|
||||
_, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("country_code") == "DE"
|
||||
|
||||
async def test_blocklist_origin_serialised_correctly(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
@@ -701,6 +744,15 @@ class TestBanTrend:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_invalid_source_returns_422(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""An invalid source value returns HTTP 422."""
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/bans/trend?source=invalid"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Empty bucket list is serialised correctly."""
|
||||
from app.models.ban import BanTrendResponse
|
||||
@@ -836,6 +888,15 @@ class TestBansByJail:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_invalid_source_returns_422(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""An invalid source value returns HTTP 422."""
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/bans/by-jail?source=invalid"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Empty jails list is serialised correctly."""
|
||||
from app.models.ban import BansByJailResponse
|
||||
|
||||
@@ -225,6 +225,32 @@ class TestHistoryList:
|
||||
_args, kwargs = mock_fn.call_args
|
||||
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:
|
||||
"""An empty history returns items=[] and total=0."""
|
||||
with patch(
|
||||
|
||||
@@ -68,7 +68,8 @@ def _make_settings() -> ServerSettingsResponse:
|
||||
db_path="/var/lib/fail2ban/fail2ban.sqlite3",
|
||||
db_purge_age=86400,
|
||||
db_max_matches=10,
|
||||
)
|
||||
),
|
||||
warnings={"db_purge_age_too_low": False},
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +94,7 @@ class TestGetServerSettings:
|
||||
data = resp.json()
|
||||
assert data["settings"]["log_level"] == "INFO"
|
||||
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:
|
||||
"""GET /api/server/settings returns 401 without session."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from app.db import init_db
|
||||
from app.services import ban_service
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -143,6 +144,29 @@ async def empty_f2b_db_path(tmp_path: Path) -> str:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -233,6 +257,20 @@ class TestListBansHappyPath:
|
||||
|
||||
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
|
||||
@@ -616,6 +654,68 @@ class TestOriginFilter:
|
||||
|
||||
assert result.total == 3
|
||||
|
||||
async def test_bans_by_country_country_code_returns_all_matched_rows(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``bans_by_country`` returns all companion rows for the selected country."""
|
||||
path = str(tmp_path / "fail2ban_country_filter.sqlite3")
|
||||
rows = [
|
||||
{
|
||||
"jail": "sshd",
|
||||
"ip": "10.0.0.1",
|
||||
"timeofban": _ONE_HOUR_AGO - i,
|
||||
"bantime": 3600,
|
||||
"bancount": 1,
|
||||
"data": {"matches": ["failed login"]},
|
||||
}
|
||||
for i in range(205)
|
||||
]
|
||||
await _create_f2b_db(path, rows)
|
||||
|
||||
from app.services import geo_service
|
||||
|
||||
geo_service._cache["10.0.0.1"] = geo_service.GeoInfo(
|
||||
country_code="DE",
|
||||
country_name="Germany",
|
||||
asn=None,
|
||||
org=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.ban_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=path),
|
||||
), patch(
|
||||
"app.services.ban_service.asyncio.create_task"
|
||||
) as mock_create_task:
|
||||
result = await ban_service.bans_by_country(
|
||||
"/fake/sock",
|
||||
"24h",
|
||||
country_code="DE",
|
||||
http_session=AsyncMock(),
|
||||
geo_cache_lookup=geo_service.lookup_cached_only,
|
||||
)
|
||||
|
||||
mock_create_task.assert_not_called()
|
||||
assert result.total == 205
|
||||
assert len(result.bans) == 205
|
||||
assert all(b.country_code == "DE" for b in result.bans)
|
||||
|
||||
geo_service.clear_cache()
|
||||
|
||||
async def test_bans_by_country_source_archive_reads_archive(
|
||||
self, app_db_with_archive: aiosqlite.Connection
|
||||
) -> None:
|
||||
"""``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)
|
||||
@@ -802,6 +902,19 @@ class TestBanTrend:
|
||||
timestamps = [b.timestamp for b in result.buckets]
|
||||
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:
|
||||
"""A ban at a known time appears in the expected bucket."""
|
||||
import time as _time
|
||||
@@ -1018,6 +1131,20 @@ class TestBansByJail:
|
||||
assert result.total == 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(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from app.db import init_db
|
||||
from app.services import history_service
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -264,6 +265,31 @@ class TestListHistory:
|
||||
assert result.page == 1
|
||||
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
|
||||
|
||||
@@ -63,6 +63,16 @@ class TestGetSettings:
|
||||
assert result.settings.log_target == "/var/log/fail2ban.log"
|
||||
assert result.settings.db_purge_age == 86400
|
||||
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:
|
||||
"""get_settings returns the correct database file path."""
|
||||
|
||||
59
backend/tests/test_tasks/test_history_sync.py
Normal file
59
backend/tests/test_tasks/test_history_sync.py
Normal 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)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
@@ -72,7 +72,7 @@ dbfile = /var/lib/fail2ban/fail2ban.sqlite3
|
||||
# Options: dbpurgeage
|
||||
# Notes.: Sets age at which bans should be purged from the database
|
||||
# Values: [ SECONDS ] Default: 86400 (24hours)
|
||||
dbpurgeage = 1d
|
||||
dbpurgeage = 648000
|
||||
|
||||
# Options: dbmaxmatches
|
||||
# Notes.: Number of matches stored in database per ticket (resolvable via
|
||||
|
||||
190
frontend/package-lock.json
generated
190
frontend/package-lock.json
generated
@@ -1,30 +1,33 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.10",
|
||||
"version": "0.9.18",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"d3-geo": "^3.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^3.8.0"
|
||||
"recharts": "^3.8.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/topojson-client": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||
"@typescript-eslint/parser": "^8.13.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
@@ -3565,23 +3568,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
|
||||
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
|
||||
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
@@ -3597,12 +3592,6 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz",
|
||||
"integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
@@ -3624,16 +3613,6 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz",
|
||||
"integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "^2",
|
||||
"@types/d3-selection": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -3652,6 +3631,7 @@
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -3696,16 +3676,25 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-simple-maps": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
|
||||
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
|
||||
"node_modules/@types/topojson-client": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-geo": "^2",
|
||||
"@types/d3-zoom": "^2",
|
||||
"@types/geojson": "*",
|
||||
"@types/react": "*"
|
||||
"@types/topojson-specification": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-specification": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
@@ -4476,28 +4465,6 @@
|
||||
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
|
||||
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
|
||||
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-selection": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
|
||||
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
@@ -4508,12 +4475,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
||||
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^2.5.0"
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
@@ -4550,12 +4520,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
|
||||
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
@@ -4592,41 +4556,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
|
||||
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
|
||||
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 2",
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-ease": "1 - 2",
|
||||
"d3-interpolate": "1 - 2",
|
||||
"d3-timer": "1 - 2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
|
||||
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-drag": "2",
|
||||
"d3-interpolate": "1 - 2",
|
||||
"d3-selection": "2",
|
||||
"d3-transition": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -5745,16 +5674,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -5982,18 +5901,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6110,23 +6017,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-simple-maps": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
||||
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-geo": "^2.0.2",
|
||||
"d3-selection": "^2.0.0",
|
||||
"d3-zoom": "^2.0.0",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.8.0 || 17.x || 18.x",
|
||||
"react-dom": "^16.8.0 || 17.x || 18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
@@ -7516,6 +7406,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/world-atlas": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.19",
|
||||
"description": "BanGUI frontend — fail2ban web management interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,14 +17,17 @@
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"d3-geo": "^3.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/topojson-client": "^3.0.0",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function fetchBans(
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): Promise<DashboardBanListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
range,
|
||||
@@ -51,6 +52,9 @@ export async function fetchBans(
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -66,11 +70,15 @@ export async function fetchBans(
|
||||
export async function fetchBanTrend(
|
||||
range: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): Promise<BanTrendResponse> {
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -86,10 +94,14 @@ export async function fetchBanTrend(
|
||||
export async function fetchBansByJail(
|
||||
range: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): Promise<BansByJailResponse> {
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function fetchHistory(
|
||||
if (query.origin) params.set("origin", query.origin);
|
||||
if (query.jail) params.set("jail", query.jail);
|
||||
if (query.ip) params.set("ip", query.ip);
|
||||
if (query.source) params.set("source", query.source);
|
||||
if (query.page !== undefined) params.set("page", String(query.page));
|
||||
if (query.page_size !== undefined)
|
||||
params.set("page_size", String(query.page_size));
|
||||
|
||||
34
frontend/src/api/map.test.ts
Normal file
34
frontend/src/api/map.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import { fetchBansByCountry } from "./map";
|
||||
import { get } from "./client";
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedGet = get as Mock;
|
||||
|
||||
describe("fetchBansByCountry", () => {
|
||||
beforeEach(() => {
|
||||
mockedGet.mockReset();
|
||||
mockedGet.mockResolvedValue({ countries: {}, country_names: {}, bans: [], total: 0 });
|
||||
});
|
||||
|
||||
it("appends country_code when provided", async () => {
|
||||
await fetchBansByCountry("24h", "all", "fail2ban", "US");
|
||||
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h&country_code=US`
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append country_code when undefined", async () => {
|
||||
await fetchBansByCountry("24h", "all", "fail2ban");
|
||||
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,18 @@ import type { BanOriginFilter } from "../types/ban";
|
||||
export async function fetchBansByCountry(
|
||||
range: TimeRange = "24h",
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
countryCode?: string,
|
||||
): Promise<BansByCountryResponse> {
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
if (countryCode) {
|
||||
params.set("country_code", countryCode);
|
||||
}
|
||||
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ interface BanTableProps {
|
||||
* Changing this value triggers a re-fetch and resets to page 1.
|
||||
*/
|
||||
origin?: BanOriginFilter;
|
||||
/**
|
||||
* Data source used for the table query.
|
||||
*/
|
||||
source?: "fail2ban" | "archive";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -186,9 +190,9 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
|
||||
* @param props.timeRange - Active time-range preset from the parent page.
|
||||
* @param props.origin - Active origin filter from the parent page.
|
||||
*/
|
||||
export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element {
|
||||
export function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin);
|
||||
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin, source);
|
||||
|
||||
const banColumns = buildBanColumns(styles);
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ interface BanTrendChartProps {
|
||||
timeRange: TimeRange;
|
||||
/** Origin filter controlling which bans are included. */
|
||||
origin: BanOriginFilter;
|
||||
/** Data source used for the chart. */
|
||||
source?: "fail2ban" | "archive";
|
||||
}
|
||||
|
||||
/** Internal chart data point shape. */
|
||||
@@ -188,9 +190,10 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
||||
export function BanTrendChart({
|
||||
timeRange,
|
||||
origin,
|
||||
source = "fail2ban",
|
||||
}: BanTrendChartProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin);
|
||||
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin, source);
|
||||
|
||||
const isEmpty = buckets.every((b) => b.count === 0);
|
||||
const entries = buildEntries(buckets, timeRange);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import {
|
||||
Divider,
|
||||
Input,
|
||||
Text,
|
||||
ToggleButton,
|
||||
Toolbar,
|
||||
@@ -35,6 +36,14 @@ export interface DashboardFilterBarProps {
|
||||
originFilter: BanOriginFilter;
|
||||
/** Called when the user selects a different origin filter. */
|
||||
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,
|
||||
originFilter,
|
||||
onOriginFilterChange,
|
||||
jail,
|
||||
onJailChange,
|
||||
ip,
|
||||
onIpChange,
|
||||
}: DashboardFilterBarProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const cardStyles = useCardStyles();
|
||||
@@ -146,6 +159,48 @@ export function DashboardFilterBar({
|
||||
))}
|
||||
</Toolbar>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
/**
|
||||
* WorldMap — SVG world map showing per-country ban counts.
|
||||
*
|
||||
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from
|
||||
* jsDelivr CDN. For each country that has bans in the selected time window,
|
||||
* the total count is displayed inside the country's borders. Clicking a
|
||||
* country filters the companion table.
|
||||
* Uses a local TopoJSON bundle and d3-geo for projection, path generation,
|
||||
* and native SVG pan/zoom behaviour.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { geoMercator, geoPath, type GeoPath } from "d3-geo";
|
||||
import { feature } from "topojson-client";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
GeoJsonProperties,
|
||||
Geometry,
|
||||
} from "geojson";
|
||||
import type {
|
||||
GeometryCollection as TopoGeometryCollection,
|
||||
Topology,
|
||||
} from "topojson-specification";
|
||||
import worldData from "world-atlas/countries-110m.json";
|
||||
import { useCardStyles } from "../theme/commonStyles";
|
||||
import type { GeoPermissibleObjects } from "d3-geo";
|
||||
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
||||
import { getBanCountColor } from "../utils/mapColors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GEO_URL =
|
||||
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAP_WIDTH = 800;
|
||||
const MAP_HEIGHT = 400;
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 8;
|
||||
const ZOOM_STEP = 0.5;
|
||||
const PAN_THRESHOLD = 3;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
mapWrapper: {
|
||||
@@ -33,6 +44,25 @@ const useStyles = makeStyles({
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
svg: {
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
touchAction: "none",
|
||||
},
|
||||
country: {
|
||||
transition: "fill 150ms ease, stroke 150ms ease",
|
||||
stroke: tokens.colorNeutralStroke2,
|
||||
strokeWidth: 0.75,
|
||||
fill: "var(--country-fill)",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
},
|
||||
countryHovered: {
|
||||
fill: "var(--country-hover-fill)",
|
||||
},
|
||||
countrySelected: {
|
||||
fill: "var(--country-selected-fill)",
|
||||
},
|
||||
countLabel: {
|
||||
fontSize: "9px",
|
||||
fontWeight: "600",
|
||||
@@ -73,195 +103,21 @@ const useStyles = makeStyles({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||||
// ---------------------------------------------------------------------------
|
||||
type TopoJsonTopology = Topology & {
|
||||
objects: {
|
||||
countries: TopoGeometryCollection;
|
||||
};
|
||||
};
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames?: Record<string, string>;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
thresholdLow: number;
|
||||
thresholdMedium: number;
|
||||
thresholdHigh: number;
|
||||
}
|
||||
type TooltipState = {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
function GeoLayer({
|
||||
countries,
|
||||
countryNames,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow,
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
}: GeoLayerProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||
|
||||
const [tooltip, setTooltip] = useState<
|
||||
| {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(cc: string | null): void => {
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
},
|
||||
[selectedCountry, onSelectCountry],
|
||||
);
|
||||
|
||||
if (geographies.length === 0) return <></>;
|
||||
|
||||
// react-simple-maps types declare path as always defined, but it can be null
|
||||
// during initial render before MapProvider context initializes. Cast to reflect
|
||||
// the true runtime type and allow safe null checking.
|
||||
const safePath = path as unknown as typeof path | null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(geographies as { rsmKey: string; id: string | number }[]).map(
|
||||
(geo) => {
|
||||
const numericId = String(geo.id);
|
||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
|
||||
// Compute the fill color based on ban count
|
||||
const fillColor = getBanCountColor(
|
||||
count,
|
||||
thresholdLow,
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
);
|
||||
|
||||
// Only calculate centroid if path is available
|
||||
let cx: number | undefined;
|
||||
let cy: number | undefined;
|
||||
if (safePath != null) {
|
||||
const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects);
|
||||
[cx, cy] = centroid;
|
||||
}
|
||||
|
||||
return (
|
||||
<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 {
|
||||
interface WorldMapProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** Optional mapping from country code to display name. */
|
||||
@@ -289,21 +145,150 @@ export function WorldMap({
|
||||
}: WorldMapProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
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 [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState>(null);
|
||||
|
||||
const handleZoomIn = (): void => {
|
||||
setZoom((z) => Math.min(z + 0.5, 8));
|
||||
};
|
||||
const zoomRef = useRef<number>(zoom);
|
||||
const centerRef = useRef<[number, number]>(center);
|
||||
const dragStateRef = useRef<{
|
||||
active: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startCenter: [number, number];
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const clickSuppressedRef = useRef<boolean>(false);
|
||||
|
||||
const handleZoomOut = (): void => {
|
||||
setZoom((z) => Math.max(z - 0.5, 1));
|
||||
};
|
||||
useEffect(() => {
|
||||
zoomRef.current = zoom;
|
||||
}, [zoom]);
|
||||
|
||||
const handleResetView = (): void => {
|
||||
setZoom(1);
|
||||
useEffect(() => {
|
||||
centerRef.current = center;
|
||||
}, [center]);
|
||||
|
||||
const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []);
|
||||
|
||||
const geoJson = useMemo(
|
||||
() =>
|
||||
feature(topology, topology.objects.countries) as FeatureCollection<
|
||||
Geometry,
|
||||
GeoJsonProperties
|
||||
>,
|
||||
[topology],
|
||||
);
|
||||
|
||||
const projection = useMemo(
|
||||
() => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson),
|
||||
[geoJson],
|
||||
);
|
||||
|
||||
const pathGenerator = useMemo<GeoPath<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],
|
||||
);
|
||||
|
||||
/** SVG-level click handler — paths never receive click when pointer capture
|
||||
* is active on the SVG, so we resolve the target via the data-cc attribute. */
|
||||
const handleSvgClick = useCallback((event: React.MouseEvent<SVGSVGElement>) => {
|
||||
const target = (event.target as Element).closest('[data-cc]');
|
||||
const cc = target?.getAttribute('data-cc') ?? null;
|
||||
if (cc) handleCountrySelect(cc);
|
||||
}, [handleCountrySelect]);
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
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;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
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]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -311,13 +296,12 @@ export function WorldMap({
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||||
>
|
||||
{/* Zoom controls */}
|
||||
<div className={styles.zoomControls}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 8}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
@@ -327,7 +311,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
@@ -337,7 +321,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleResetView}
|
||||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
||||
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
|
||||
title="Reset view"
|
||||
aria-label="Reset view"
|
||||
>
|
||||
@@ -345,34 +329,111 @@ export function WorldMap({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||||
width={800}
|
||||
height={400}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country."
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onClick={handleSvgClick}
|
||||
>
|
||||
<ZoomableGroup
|
||||
zoom={zoom}
|
||||
center={center}
|
||||
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
|
||||
setZoom(newZoom);
|
||||
setCenter(coordinates);
|
||||
}}
|
||||
minZoom={1}
|
||||
maxZoom={8}
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
thresholdMedium={thresholdMedium}
|
||||
thresholdHigh={thresholdHigh}
|
||||
/>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||
{countryFeatures.map((featureItem) => {
|
||||
const rawId = featureItem.id;
|
||||
const numericId = String(Number(rawId));
|
||||
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count = cc !== null ? countries[cc] ?? 0 : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
const pathString = pathGenerator(featureItem) ?? "";
|
||||
if (!pathString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={String(rawId)}>
|
||||
<path
|
||||
d={pathString}
|
||||
data-cc={cc ?? undefined}
|
||||
role={cc ? "button" : undefined}
|
||||
tabIndex={cc ? 0 : undefined}
|
||||
aria-label={
|
||||
cc
|
||||
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
|
||||
isSelected ? " (selected)" : ""
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={isSelected || undefined}
|
||||
className={`${styles.country} ${
|
||||
isSelected ? styles.countrySelected : ""
|
||||
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
|
||||
style={
|
||||
{
|
||||
["--country-fill" as string]: fillColor,
|
||||
["--country-hover-fill" as string]: isSelected
|
||||
? tokens.colorBrandBackgroundHover
|
||||
: tokens.colorBrandBackground2,
|
||||
["--country-selected-fill" as string]: tokens.colorBrandBackground,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (cc) handleCountrySelect(cc);
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (cc && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void => {
|
||||
if (!cc) return;
|
||||
setHoveredCountry(cc);
|
||||
setTooltip({
|
||||
cc,
|
||||
count,
|
||||
name: countryNames?.[cc] ?? cc,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
}}
|
||||
onMouseMove={(event): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? { ...current, x: event.clientX, y: event.clientY }
|
||||
: current,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHoveredCountry(null);
|
||||
setTooltip(null);
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,4 +125,47 @@ describe("DashboardFilterBar", () => {
|
||||
expect(onTimeRangeChange).toHaveBeenCalledOnce();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
||||
vi.mock("react-simple-maps", () => ({
|
||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Geography: ({ children }: { children?: React.ReactNode }) => <g>{children}</g>,
|
||||
useGeographies: () => ({
|
||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
||||
path: { centroid: () => [10, 10] },
|
||||
vi.mock(
|
||||
"world-atlas/countries-110m.json",
|
||||
() => ({
|
||||
default: {
|
||||
type: "Topology",
|
||||
objects: {
|
||||
countries: {
|
||||
type: "GeometryCollection",
|
||||
geometries: [
|
||||
{
|
||||
type: "Polygon",
|
||||
arcs: [[0]],
|
||||
id: "840",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
arcs: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
|
||||
transform: {
|
||||
scale: [1, 1],
|
||||
translate: [0, 0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
import { WorldMap } from "../WorldMap";
|
||||
|
||||
@@ -34,16 +49,20 @@ describe("WorldMap", () => {
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Tooltip should not be present initially
|
||||
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 });
|
||||
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("United States");
|
||||
expect(tooltip).toHaveTextContent("42 bans");
|
||||
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
||||
|
||||
fireEvent.mouseLeave(countryButton);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"4": "AF",
|
||||
"8": "AL",
|
||||
"10": "AQ",
|
||||
"12": "DZ",
|
||||
"16": "AS",
|
||||
"20": "AD",
|
||||
@@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"148": "TD",
|
||||
"152": "CL",
|
||||
"156": "CN",
|
||||
"158": "TW",
|
||||
"162": "CX",
|
||||
"166": "CC",
|
||||
"170": "CO",
|
||||
@@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"250": "FR",
|
||||
"254": "GF",
|
||||
"258": "PF",
|
||||
"260": "TF",
|
||||
"262": "DJ",
|
||||
"266": "GA",
|
||||
"268": "GE",
|
||||
@@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"372": "IE",
|
||||
"376": "IL",
|
||||
"380": "IT",
|
||||
"384": "CI",
|
||||
"388": "JM",
|
||||
"392": "JP",
|
||||
"398": "KZ",
|
||||
|
||||
74
frontend/src/hooks/__tests__/useActionConfig.test.ts
Normal file
74
frontend/src/hooks/__tests__/useActionConfig.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
frontend/src/hooks/__tests__/useFilterConfig.test.ts
Normal file
72
frontend/src/hooks/__tests__/useFilterConfig.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
* React hook for loading and updating a single parsed action config.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { fetchAction, updateAction } from "../api/config";
|
||||
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
|
||||
@@ -23,12 +24,18 @@ export interface UseActionConfigResult {
|
||||
* @param name - Action base name (e.g. ``"iptables"``).
|
||||
*/
|
||||
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<
|
||||
ActionConfig,
|
||||
ActionConfigUpdate
|
||||
>({
|
||||
fetchFn: () => fetchAction(name),
|
||||
saveFn: (update) => updateAction(name, update),
|
||||
fetchFn,
|
||||
saveFn,
|
||||
mergeOnSave: (prev, update) =>
|
||||
prev
|
||||
? {
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface UseBanTrendResult {
|
||||
export function useBanTrend(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter,
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): UseBanTrendResult {
|
||||
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
|
||||
const [bucketSize, setBucketSize] = useState<string>("1h");
|
||||
@@ -58,7 +59,7 @@ export function useBanTrend(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBanTrend(timeRange, origin)
|
||||
fetchBanTrend(timeRange, origin, source)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setBuckets(data.buckets);
|
||||
@@ -73,7 +74,7 @@ export function useBanTrend(
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [timeRange, origin]);
|
||||
}, [timeRange, origin, source]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface UseBansResult {
|
||||
export function useBans(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): UseBansResult {
|
||||
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
@@ -51,16 +52,16 @@ export function useBans(
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
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(() => {
|
||||
setPage(1);
|
||||
}, [timeRange, origin]);
|
||||
}, [timeRange, origin, source]);
|
||||
|
||||
const doFetch = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin);
|
||||
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin, source);
|
||||
setBanItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err: unknown) {
|
||||
@@ -68,7 +69,7 @@ export function useBans(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange, page, origin]);
|
||||
}, [timeRange, page, origin, source]);
|
||||
|
||||
// Stable ref to the latest doFetch so the refresh callback is always current.
|
||||
const doFetchRef = useRef(doFetch);
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface UseDashboardCountryDataResult {
|
||||
export function useDashboardCountryData(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter,
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): UseDashboardCountryDataResult {
|
||||
const [countries, setCountries] = useState<Record<string, number>>({});
|
||||
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
||||
@@ -67,7 +68,7 @@ export function useDashboardCountryData(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBansByCountry(timeRange, origin)
|
||||
fetchBansByCountry(timeRange, origin, source)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCountries(data.countries);
|
||||
@@ -85,7 +86,7 @@ export function useDashboardCountryData(
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [timeRange, origin]);
|
||||
}, [timeRange, origin, source]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* React hook for loading and updating a single parsed filter config.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { fetchParsedFilter, updateParsedFilter } from "../api/config";
|
||||
import type { FilterConfig, FilterConfigUpdate } from "../types/config";
|
||||
@@ -23,12 +24,18 @@ export interface UseFilterConfigResult {
|
||||
* @param name - Filter base name (e.g. ``"sshd"``).
|
||||
*/
|
||||
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<
|
||||
FilterConfig,
|
||||
FilterConfigUpdate
|
||||
>({
|
||||
fetchFn: () => fetchParsedFilter(name),
|
||||
saveFn: (update) => updateParsedFilter(name, update),
|
||||
fetchFn,
|
||||
saveFn,
|
||||
mergeOnSave: (prev, update) =>
|
||||
prev
|
||||
? {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* React hook for loading and updating a single parsed jail.d config file.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
|
||||
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
|
||||
@@ -21,12 +22,18 @@ export interface UseJailFileConfigResult {
|
||||
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
|
||||
*/
|
||||
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<
|
||||
JailFileConfig,
|
||||
JailFileConfigUpdate
|
||||
>({
|
||||
fetchFn: () => fetchParsedJailFile(filename),
|
||||
saveFn: (update) => updateParsedJailFile(filename, update),
|
||||
fetchFn,
|
||||
saveFn,
|
||||
mergeOnSave: (prev, update) =>
|
||||
update.jails != null && prev
|
||||
? { ...prev, jails: { ...prev.jails, ...update.jails } }
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface UseMapDataResult {
|
||||
export function useMapData(
|
||||
range: TimeRange = "24h",
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
countryCode?: string,
|
||||
): UseMapDataResult {
|
||||
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -64,7 +66,7 @@ export function useMapData(
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
fetchBansByCountry(range, origin)
|
||||
fetchBansByCountry(range, origin, source, countryCode)
|
||||
.then((resp) => {
|
||||
setData(resp);
|
||||
})
|
||||
@@ -75,7 +77,7 @@ export function useMapData(
|
||||
setLoading(false);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
}, [range, origin]);
|
||||
}, [range, origin, source, countryCode]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
@@ -97,3 +99,21 @@ export function useMapData(
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -71,8 +71,10 @@ export function DashboardPage(): React.JSX.Element {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
|
||||
const source = timeRange === "24h" ? "fail2ban" : "archive";
|
||||
|
||||
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
|
||||
useDashboardCountryData(timeRange, originFilter);
|
||||
useDashboardCountryData(timeRange, originFilter, source);
|
||||
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
|
||||
@@ -86,12 +88,14 @@ export function DashboardPage(): React.JSX.Element {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Global filter bar */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<DashboardFilterBar
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={setOriginFilter}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
|
||||
<DashboardFilterBar
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={setOriginFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Ban Trend section */}
|
||||
@@ -103,7 +107,7 @@ export function DashboardPage(): React.JSX.Element {
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
<BanTrendChart timeRange={timeRange} origin={originFilter} />
|
||||
<BanTrendChart timeRange={timeRange} origin={originFilter} source={source} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +158,7 @@ export function DashboardPage(): React.JSX.Element {
|
||||
|
||||
{/* Ban table */}
|
||||
<div className={styles.tabContent}>
|
||||
<BanTable timeRange={timeRange} origin={originFilter} />
|
||||
<BanTable timeRange={timeRange} origin={originFilter} source={source} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Rows with repeatedly-banned IPs are highlighted in amber.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
DataGridHeader,
|
||||
DataGridHeaderCell,
|
||||
DataGridRow,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
@@ -82,11 +81,6 @@ const useStyles = makeStyles({
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
filterLabel: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXS,
|
||||
},
|
||||
tableWrapper: {
|
||||
overflow: "auto",
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -374,11 +387,12 @@ export function HistoryPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
// Filter state
|
||||
const [range, setRange] = useState<TimeRange>("24h");
|
||||
const [range, setRange] = useState<TimeRange>("7d");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
const [jailFilter, setJailFilter] = useState("");
|
||||
const [ipFilter, setIpFilter] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
||||
source: "archive",
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
@@ -388,15 +402,24 @@ export function HistoryPage(): React.JSX.Element {
|
||||
const { items, total, page, loading, error, setPage, refresh } =
|
||||
useHistory(appliedQuery);
|
||||
|
||||
const applyFilters = useCallback((): void => {
|
||||
setAppliedQuery({
|
||||
range: range,
|
||||
useEffect((): void => {
|
||||
const nextQuery: HistoryQuery = {
|
||||
range,
|
||||
origin: originFilter !== "all" ? originFilter : undefined,
|
||||
jail: jailFilter.trim() || undefined,
|
||||
ip: ipFilter.trim() || undefined,
|
||||
source: "archive",
|
||||
page: 1,
|
||||
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));
|
||||
|
||||
@@ -456,71 +479,17 @@ export function HistoryPage(): React.JSX.Element {
|
||||
onOriginFilterChange={(value) => {
|
||||
setOriginFilter(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.filterLabel}>
|
||||
<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 });
|
||||
jail={jailFilter}
|
||||
onJailChange={(value) => {
|
||||
setJailFilter(value);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
ip={ipFilter}
|
||||
onIpChange={(value) => {
|
||||
setIpFilter(value);
|
||||
}}
|
||||
/>
|
||||
</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 */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
makeStyles,
|
||||
tokens,
|
||||
} 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 { WorldMap } from "../components/WorldMap";
|
||||
import { useMapData } from "../hooks/useMapData";
|
||||
@@ -59,6 +64,13 @@ const useStyles = makeStyles({
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
},
|
||||
stickyHeaderCell: {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
boxShadow: `0 1px 0 ${tokens.colorNeutralStroke2}`,
|
||||
},
|
||||
filterBar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -68,6 +80,18 @@ const useStyles = makeStyles({
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
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,
|
||||
position: "sticky",
|
||||
bottom: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,9 +103,22 @@ export function MapPage(): React.JSX.Element {
|
||||
const [range, setRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
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 } =
|
||||
useMapData(range, originFilter);
|
||||
useMapData(range, originFilter, source, selectedCountry ?? undefined);
|
||||
|
||||
// True after the first successful data load — keeps the map mounted
|
||||
// during subsequent re-fetches so country selection gives instant feedback.
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!loading && !error) setHasLoadedOnce(true);
|
||||
}, [loading, error]);
|
||||
|
||||
const {
|
||||
thresholds: mapThresholds,
|
||||
@@ -99,6 +136,10 @@ export function MapPage(): React.JSX.Element {
|
||||
}
|
||||
}, [mapThresholdError]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [range, originFilter, selectedCountry, bans, pageSize]);
|
||||
|
||||
/** Bans visible in the companion table (filtered by selected country). */
|
||||
const visibleBans = useMemo(() => {
|
||||
if (!selectedCountry) return bans;
|
||||
@@ -109,6 +150,15 @@ export function MapPage(): React.JSX.Element {
|
||||
? (countryNames[selectedCountry] ?? selectedCountry)
|
||||
: 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 (
|
||||
<div className={styles.root}>
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
@@ -152,7 +202,8 @@ export function MapPage(): React.JSX.Element {
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
{/* Initial load spinner — only shown before the first data arrives. */}
|
||||
{loading && !error && !hasLoadedOnce && (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
|
||||
<Spinner label="Loading map data…" />
|
||||
</div>
|
||||
@@ -160,8 +211,10 @@ export function MapPage(): React.JSX.Element {
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* World map */}
|
||||
{/* Keep the map mounted after first load so clicking a country gives */}
|
||||
{/* immediate visual feedback before the filtered data arrives. */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
{!error && hasLoadedOnce && (
|
||||
<WorldMap
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
@@ -199,28 +252,31 @@ export function MapPage(): React.JSX.Element {
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Summary line */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period
|
||||
{" · "}
|
||||
{String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected
|
||||
</Text>
|
||||
{!error && hasLoadedOnce && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
|
||||
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period
|
||||
{" · "}
|
||||
{String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected
|
||||
</Text>
|
||||
{loading && <Spinner size="tiny" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Companion bans table */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
<div className={styles.tableWrapper}>
|
||||
{!error && hasLoadedOnce && (
|
||||
<div className={styles.tableWrapper} style={{ opacity: loading ? 0.5 : 1, transition: "opacity 150ms" }}>
|
||||
<Table size="small" aria-label="Bans list">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>IP Address</TableHeaderCell>
|
||||
<TableHeaderCell>Jail</TableHeaderCell>
|
||||
<TableHeaderCell>Banned At</TableHeaderCell>
|
||||
<TableHeaderCell>Country</TableHeaderCell>
|
||||
<TableHeaderCell>Origin</TableHeaderCell>
|
||||
<TableHeaderCell>Times Banned</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>IP Address</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Jail</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Banned At</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Country</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Origin</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Times Banned</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -235,7 +291,7 @@ export function MapPage(): React.JSX.Element {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
visibleBans.map((ban) => (
|
||||
pageBans.map((ban) => (
|
||||
<TableRow key={`${ban.ip}-${ban.banned_at}`}>
|
||||
<TableCell>
|
||||
<TableCellLayout>{ban.ip}</TableCellLayout>
|
||||
@@ -282,6 +338,53 @@ export function MapPage(): React.JSX.Element {
|
||||
)}
|
||||
</TableBody>
|
||||
</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>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { HistoryPage } from "../HistoryPage";
|
||||
|
||||
let lastQuery: Record<string, unknown> | null = null;
|
||||
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
|
||||
console.log("mockUseHistory called", query);
|
||||
lastQuery = query;
|
||||
return {
|
||||
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),
|
||||
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../components/WorldMap", () => ({
|
||||
vi.mock("../../components/WorldMap", () => ({
|
||||
WorldMap: () => <div data-testid="world-map" />,
|
||||
}));
|
||||
|
||||
vi.mock("../api/config", () => ({
|
||||
vi.mock("../../api/config", () => ({
|
||||
fetchMapColorThresholds: async () => ({
|
||||
threshold_low: 10,
|
||||
threshold_medium: 50,
|
||||
@@ -35,8 +35,10 @@ vi.mock("../api/config", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { HistoryPage } from "../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();
|
||||
|
||||
render(
|
||||
@@ -45,14 +47,31 @@ describe("HistoryPage", () => {
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Initial load should include the default query.
|
||||
expect(lastQuery).toEqual({ page_size: 50 });
|
||||
// Initial load should include the auto-applied default query.
|
||||
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: /Blocklist/i }));
|
||||
await user.click(screen.getByRole("button", { name: /Apply/i }));
|
||||
await waitFor(() => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,42 +2,43 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { getLastArgs, setMapData } from "../../hooks/useMapData";
|
||||
import { MapPage } from "../MapPage";
|
||||
|
||||
const mockFetchMapColorThresholds = vi.fn(async () => ({
|
||||
threshold_low: 10,
|
||||
threshold_medium: 50,
|
||||
threshold_high: 100,
|
||||
}));
|
||||
|
||||
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
|
||||
const mockUseMapData = vi.fn((range: string, origin: string) => {
|
||||
lastArgs = { range, origin };
|
||||
return {
|
||||
vi.mock("../../hooks/useMapData", () => {
|
||||
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
|
||||
let dataState = {
|
||||
countries: {},
|
||||
countryNames: {},
|
||||
bans: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
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", () => ({
|
||||
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
|
||||
vi.mock("../../api/config", () => ({
|
||||
fetchMapColorThresholds: vi.fn(async () => ({
|
||||
threshold_low: 10,
|
||||
threshold_medium: 50,
|
||||
threshold_high: 100,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../api/config", async () => ({
|
||||
fetchMapColorThresholds: mockFetchMapColorThresholds,
|
||||
}));
|
||||
|
||||
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" />;
|
||||
},
|
||||
vi.mock("../../components/WorldMap", () => ({
|
||||
WorldMap: () => <div data-testid="world-map" />,
|
||||
}));
|
||||
|
||||
describe("MapPage", () => {
|
||||
@@ -51,17 +52,63 @@ describe("MapPage", () => {
|
||||
);
|
||||
|
||||
// Initial load should call useMapData with default filters.
|
||||
expect(lastArgs).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: {} });
|
||||
expect(getLastArgs()).toEqual({ range: "24h", origin: "all" });
|
||||
|
||||
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 }));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface HistoryQuery {
|
||||
origin?: BanOriginFilter;
|
||||
jail?: string;
|
||||
ip?: string;
|
||||
source?: "fail2ban" | "archive";
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user