10 Commits

Author SHA1 Message Date
59da34dc3b chore: release v0.9.8 2026-03-17 18:22:02 +01:00
90f54cf39c chore: release v0.9.6 2026-03-17 18:21:46 +01:00
93d26e3c60 chore: release v0.9.7 2026-03-17 18:18:49 +01:00
954dcf7ea6 fix: remove invalid --security-opt flag from push.sh build commands 2026-03-17 18:18:25 +01:00
bf8144916a chore: release v0.9.6 2026-03-17 18:16:59 +01:00
481daa4e1a fix: resolve TS build errors and suppress rootless podman capability warnings
- Add Toolbar and ToolbarButton to HistoryPage imports
- Add Tooltip import and filterBar style to MapPage
- Fix JailsTab test: use new Set<string>() instead of [] for activeJails
- Add --security-opt=no-new-privileges:true to push.sh build commands
2026-03-17 18:16:16 +01:00
889976c7ee chore: release v0.9.5 2026-03-17 18:06:03 +01:00
d3d2cb0915 Add repo-root pytest config so async tests run from root 2026-03-17 17:55:54 +01:00
bf82e38b6e Fix blocklist-import bantime, unify filter bar, and improve config navigation 2026-03-17 11:31:46 +01:00
e98fd1de93 Fix global version handling and unify app version across backend/frontend 2026-03-17 09:06:42 +01:00
29 changed files with 620 additions and 642 deletions

View File

@@ -1 +1 @@
v0.9.4
v0.9.8

View File

@@ -18,8 +18,8 @@ logpath = /dev/null
backend = auto
maxretry = 1
findtime = 1d
# Block imported IPs for one week.
bantime = 1w
# Block imported IPs for 24 hours.
bantime = 86400
# Never ban the Docker bridge network or localhost.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -56,11 +56,8 @@ echo " Registry : ${REGISTRY}"
echo " Tag : ${TAG}"
echo "============================================"
if [[ "${ENGINE}" == "podman" ]]; then
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
err "Not logged in. Run:\n podman login ${REGISTRY}"
fi
fi
log "Logging in to ${REGISTRY}"
"${ENGINE}" login "${REGISTRY}"
# ---------------------------------------------------------------------------
# Build

View File

@@ -12,496 +12,81 @@ This document breaks the entire BanGUI project into development stages, ordered
---
### BACKEND
### Task 1 — Blocklist-import jail ban time must be 24 hours
**Status:** ✅ Done
**Context**
When the blocklist importer bans an IP it calls `jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, ip)` (see `backend/app/services/blocklist_service.py`, constant `BLOCKLIST_JAIL = "blocklist-import"`). That call sends `set blocklist-import banip <ip>` to fail2ban, which applies the jail's configured `bantime`. There is currently no guarantee that the `blocklist-import` jail's `bantime` is 86 400 s (24 h), so imported IPs may be released too early or held indefinitely depending on the jail template.
**What to do**
1. Locate every place the `blocklist-import` jail is defined or provisioned — check `Docker/fail2ban-dev-config/`, `Docker/Dockerfile.backend`, any jail template files, and the `setup_service.py` / `SetupPage.tsx` flow.
2. Ensure the `blocklist-import` jail is created with `bantime = 86400` (24 h). If the jail is created at runtime by the setup service, add or update the `bantime` parameter there. If it is defined in a static config file, set `bantime = 86400` in that file.
3. Verify that the existing `jail_service.ban_ip` call in `blocklist_service.import_source` does not need a per-call duration override; the jail-level default of 86 400 s is sufficient.
4. Add or update the relevant unit/integration test in `backend/tests/` to assert that the blocklist-import jail is set up with a 24-hour bantime.
---
#### TASK B-1 — Create a `fail2ban_db` repository for direct fail2ban database queries
### Task 2 — Clicking a jail in Jail Overview navigates to Configuration → Jails
**Violated rule:** Refactoring.md §2.2 — Services must not perform direct `aiosqlite` calls; go through a repository.
**Status:** ✅ Done
**Files affected:**
- `backend/app/services/ban_service.py` — lines 247, 398, 568, 646: four separate `aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True)` blocks that execute raw SQL against the fail2ban SQLite database.
- `backend/app/services/history_service.py` — lines 118, 208: two more direct `aiosqlite.connect()` blocks against the fail2ban database.
**Context**
**What to do:**
`JailsPage.tsx` renders a "Jail Overview" data grid with one row per jail (see `frontend/src/pages/JailsPage.tsx`). Clicking a row currently does nothing. `ConfigPage.tsx` hosts a tab bar with a "Jails" tab that renders `JailsTab`, which already uses a list/detail layout where a jail can be selected from the left pane.
1. Create `backend/app/repositories/fail2ban_db_repo.py`.
2. Move all SQL that touches the fail2ban database into clearly named async functions in that module. Each function must accept the fail2ban database path (`db_path: str`) as a parameter (connection management stays inside the repository function, since the fail2ban database is an external, read-only resource not managed by BanGUI's own connection pool).
- `get_currently_banned(db_path, jail_filter, since) -> list[BanRecord]`
- `get_ban_counts_by_bucket(db_path, ...) -> list[int]`
- `check_db_nonempty(db_path) -> bool`
- `get_history_for_ip(db_path, ip) -> list[HistoryRecord]`
- `get_history_page(db_path, ...) -> tuple[list[HistoryRecord], int]`
— Adjust signatures as needed to cover all query sites.
3. Replace the inline `aiosqlite.connect` blocks in `ban_service.py` and `history_service.py` with calls to the new repository functions.
4. Add the new repository to `backend/tests/test_repositories/` with unit tests that mock the SQLite file.
**What to do**
1. In `JailsPage.tsx`, make each jail name cell (or the entire row) a clickable element that navigates to `/config` with state `{ tab: "jails", jail: "<jailName>" }`. Use `useNavigate` from `react-router-dom`; the existing `Link` import can be used or replaced with a programmatic navigate.
2. In `ConfigPage.tsx`, read the location state on mount. If `state.tab` is `"jails"`, set the active tab to `"jails"`. Pass `state.jail` down to `<JailsTab initialJail={state.jail} />`.
3. In `JailsTab.tsx`, accept an optional `initialJail?: string` prop. When it is provided, pre-select that jail in the left-pane list on first render (i.e. set the selected jail state to the jail whose name matches `initialJail`). This should scroll the item into view if the list is long.
4. Add a frontend unit test in `frontend/src/pages/__tests__/` that mounts `JailsPage` with a mocked jail list, clicks a jail row, and asserts that `useNavigate` was called with the correct path and state.
---
#### TASK B-2 — Remove direct SQL query from `routers/geo.py`
### Task 3 — Setting bantime / findtime throws 400 error due to unsupported `backend` set command
**Violated rule:** Refactoring.md §2.1 — Routers must contain zero business logic; no SQL or repository imports.
**Status:** ✅ Done
**Files affected:**
- `backend/app/routers/geo.py` — lines 157165: the `re_resolve_geo` handler runs `db.execute("SELECT ip FROM geo_cache WHERE country_code IS NULL")` directly.
**Context**
**What to do:**
Editing ban time or find time in Configuration → Jails triggers an auto-save that sends the full `JailConfigUpdate` payload including the `backend` field. `config_service.update_jail_config` then calls `set <jail> backend <value>` on the fail2ban socket, which returns error code 1 with the message `Invalid command 'backend' (no set action or not yet implemented)`. Fail2ban does not support changing a jail's backend at runtime; it must be set before the jail starts.
1. Add a function `get_unresolved_ips(db: aiosqlite.Connection) -> list[str]` to the appropriate repository (`geo_cache_repo.py` — create it if it does not yet exist, or add it to `settings_repo.py` if the table belongs there).
2. In the router handler, replace the inline SQL block with a single call to the new repository function via `geo_service` (preferred) or directly if the service layer already handles this path.
3. The final handler body must contain no `db.execute` calls.
**What to do**
**Backend** (`backend/app/services/config_service.py`):
1. Remove the `if update.backend is not None: await _set("backend", update.backend)` block from `update_jail_config`. Setting `backend` via the socket is not supported by fail2ban and will always fail.
2. `log_encoding` has the same constraint — verify whether `set <jail> logencoding` is supported at runtime. If it is not, remove it too. If it is supported, leave it.
3. Ensure the function still accepts and stores the `backend` value in the Pydantic model for read purposes; do not remove it from `JailConfigUpdate` or the response model.
**Frontend** (`frontend/src/components/config/JailsTab.tsx`):
4. Remove `backend` (and `log_encoding` if step 2 confirms it is unsupported) from the `autoSavePayload` memo so the field is never sent in the PATCH/PUT body. The displayed value should remain read-only — show them as plain text or a disabled select so the user can see the current value without being able to trigger the broken set command.
**Tests**:
5. Add or update the backend test for `update_jail_config` to assert that no `set … backend` command is issued, and that a payload containing a `backend` field does not cause an error.
---
#### TASK B-3 — Remove repository import from `routers/blocklist.py`
### Task 4 — Unify filter bar: use `DashboardFilterBar` in World Map and History pages
**Violated rule:** Refactoring.md §2.1 — Routers must not import from repositories; all data access must go through services.
**Status:** ✅ Done
**Files affected:**
- `backend/app/routers/blocklist.py` — line 45: `from app.repositories import import_log_repo`; the `get_import_log` handler (around line 220) calls `import_log_repo.list_logs()` directly.
**Context**
**What to do:**
`DashboardPage.tsx` uses the shared `<DashboardFilterBar>` component for its time-range and origin-filter controls. `MapPage.tsx` and `HistoryPage.tsx` each implement their own ad-hoc filter UI: `MapPage` uses a Fluent UI `<Select>` for time range plus an inline Toolbar for origin filter; `HistoryPage` uses a `<Select>` for time range with no origin filter toggle. The `DashboardFilterBar` already supports both `TimeRange` and `BanOriginFilter` with the exact toggle-button style shown in the design reference. All three pages should share the same filter appearance and interaction patterns.
1. Add a `list_import_logs(db, source_id, page, page_size) -> tuple[list[ImportRunResult], int]` method to `blocklist_service.py` (it can be a thin wrapper that calls `import_log_repo.list_logs` internally).
2. In the router, replace the direct `import_log_repo.list_logs(...)` call with `await blocklist_service.list_import_logs(...)`.
3. Remove the `import_log_repo` import from the router.
**What to do**
1. **`MapPage.tsx`**: Replace the custom time-range `<Select>` and the inline origin-filter Toolbar with `<DashboardFilterBar timeRange={range} onTimeRangeChange={setRange} originFilter={originFilter} onOriginFilterChange={setOriginFilter} />`. Remove the now-unused `TIME_RANGE_OPTIONS` constant and the `BAN_ORIGIN_FILTER_LABELS` inline usage. Pass `originFilter` to `useMapData` if it does not already receive it (check the hook signature).
2. **`HistoryPage.tsx`**: Replace the custom time-range `<Select>` with `<DashboardFilterBar>`. Add an `originFilter` state (`BanOriginFilter`, default `"all"`) and wire it through `<DashboardFilterBar onOriginFilterChange={setOriginFilter} />`. Pass the origin filter into the `useHistory` query so the backend receives it. If `useHistory` / `HistoryQuery` does not yet accept `origin_filter`, add the parameter to the type and the hook's fetch call.
3. Remove any local `filterBar` style definitions from `MapPage.tsx` and `HistoryPage.tsx` that duplicate what `DashboardFilterBar` already provides.
4. Ensure the `DashboardFilterBar` component's props interface (`DashboardFilterBarProps` in `frontend/src/components/DashboardFilterBar.tsx`) is not changed in a breaking way; only the call sites change.
5. Update or add component tests for `MapPage` and `HistoryPage` to assert that `DashboardFilterBar` is rendered and that changing the time range or origin filter updates the displayed data.
---
#### TASK B-4 — Move `conffile_parser.py` from `services/` to `utils/`
**Violated rule:** Refactoring.md §2.2 and Architecture §2.1 — `services/` is for business logic. `conffile_parser.py` is a pure, stateless parsing library with no framework dependencies (no FastAPI, no aiosqlite). It belongs in `utils/`.
**Files affected:**
- `backend/app/services/conffile_parser.py` — all callers that import from `app.services.conffile_parser`.
**What to do:**
1. Move the file: `backend/app/services/conffile_parser.py``backend/app/utils/conffile_parser.py`.
2. Update every import in the codebase from `from app.services.conffile_parser import ...` to `from app.utils.conffile_parser import ...`.
3. Run the full test suite to confirm nothing is broken.
---
#### TASK B-5 — Create a `geo_cache_repo` and remove direct SQL from `geo_service.py`
**Violated rule:** Refactoring.md §2.2 — Services must not execute raw SQL; go through a repository.
**Files affected:**
- `backend/app/services/geo_service.py` — multiple direct `db.execute` / `db.executemany` calls in `cache_stats()` (line 187), `load_cache_from_db()` (line 271), `_persist_entry()` (lines 304316), `_persist_neg_entry()` (lines 329338), `flush_dirty()` (lines 795+), and geo-data batch persist blocks (lines 588612).
**What to do:**
1. Create `backend/app/repositories/geo_cache_repo.py` with typed async functions for every SQL operation currently inline in `geo_service.py`:
- `load_all(db) -> list[GeoCacheRow]`
- `upsert_entry(db, geo_row) -> None`
- `upsert_neg_entry(db, ip) -> None`
- `flush_dirty(db, entries) -> int`
- `get_stats(db) -> dict[str, int]`
- `get_unresolved_ips(db) -> list[str]` (also needed by B-2)
2. Replace every `db.execute` / `db.executemany` call in `geo_service.py` with calls to the new repository.
3. Add tests in `backend/tests/test_repositories/test_geo_cache_repo.py`.
---
#### TASK B-6 — Remove direct SQL from `tasks/geo_re_resolve.py`
**Violated rule:** Refactoring.md §2.5 — Tasks must not use repositories directly; they must call a service method.
**Files affected:**
- `backend/app/tasks/geo_re_resolve.py` — line 53: `async with db.execute("SELECT ip FROM geo_cache WHERE country_code IS NULL")`.
**What to do:**
After completing TASK B-5, a `geo_service` method (or via `geo_cache_repo` through `geo_service`) that returns unresolved IPs will exist.
1. Replace the inline SQL block in `_run_re_resolve` with a call to that service method (e.g., `unresolved = await geo_service.get_unresolved_ips(db)`).
2. The task function must contain no `db.execute` calls of its own.
---
#### TASK B-7 — Replace `Any` type annotations in `ban_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/ban_service.py` — lines 192, 271, 346, 434, 455: uses of `Any` for `geo_enricher` parameter and `geo_map` dict value type.
**What to do:**
1. Define a precise callable type alias for the geo enricher, e.g.:
```python
from collections.abc import Awaitable, Callable
GeoEnricher: TypeAlias = Callable[[str], Awaitable[GeoInfo | None]]
```
2. Replace `geo_enricher: Any | None` with `geo_enricher: GeoEnricher | None` (both occurrences).
3. Replace `geo_map: dict[str, Any]` with `geo_map: dict[str, GeoInfo]` (both occurrences).
4. Replace the inner `_safe_lookup` return type `tuple[str, Any]` with `tuple[str, GeoInfo | None]`.
5. Run `mypy --strict` or `pyright` to confirm zero remaining type errors in this file.
---
#### TASK B-8 — Remove `print()` from `geo_service.py` docstring example
**Violated rule:** Refactoring.md §4 / Backend-Development.md §2 — Never use `print()` in production code; use `structlog`.
**Files affected:**
- `backend/app/services/geo_service.py` — line 33: `print(info.country_code) # "DE"` appears inside a module-level docstring usage example.
**What to do:**
Remove or rewrite the docstring snippet so it does not contain a bare `print()` call. If the example is kept, annotate it clearly as a documentation-only code block that should not be copied into production code, or replace with a comment like `# info.country_code == "DE"`.
---
#### TASK B-9 — Remove direct SQL from `main.py` lifespan into `geo_service`
**Violated rule:** Refactoring.md §2 — Application startup code must not execute raw SQL; data-access logic belongs in a repository (or, when count semantics belong to a domain concern, a service method).
**Files affected:**
- `backend/app/main.py` — lines 164168: the lifespan handler runs `db.execute("SELECT COUNT(*) FROM geo_cache WHERE country_code IS NULL")` directly to log a startup warning about unresolved geo entries.
**What to do:**
1. After TASK B-5 is complete, `geo_cache_repo` will expose a `get_stats(db) -> dict[str, int]` function (or a dedicated `count_unresolved(db) -> int`). Use that.
2. If B-5 is not yet merged, add an interim function `count_unresolved(db: aiosqlite.Connection) -> int` to `geo_cache_repo.py` now and call it from `geo_service` as `geo_service.count_unresolved_cached(db) -> Awaitable[int]`.
3. Replace the inline `async with db.execute(...)` block in `main.py` with a single `await geo_service.count_unresolved_cached(db)` call.
4. The `main.py` lifespan function must contain no `db.execute` calls of its own.
---
#### TASK B-10 — Replace `Any` type usage in `history_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/history_service.py` — uses `Any` for `geo_enricher` and query parameter lists.
**What to do:**
1. Define a shared `GeoEnricher` type alias (e.g., in `app/services/geo_service.py` or a new `app/models/geo.py`) similar to TASK B-7.
2. Update `history_service.py` to use `GeoEnricher | None` for the `geo_enricher` parameter.
3. Replace `list[Any]` for SQL parameters with a more precise type (e.g., `list[object]` or a custom `SqlParam` alias).
4. Run `mypy --strict` or `pyright` to confirm there are no remaining `Any` usages in `history_service.py`.
---
#### TASK B-11 — Reduce `Any` usage in `server_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/server_service.py` — uses `Any` for raw socket response values and command parameters.
**What to do:**
1. Define typed aliases for the expected response and command shapes used by `Fail2BanClient` (e.g., `Fail2BanResponse = tuple[int, object]`, `Fail2BanCommand = list[str | int | None]`).
2. Replace `Any` with those aliases in `_ok`, `_safe_get`, and other helper functions.
3. Ensure the public API functions (`get_settings`, etc.) have explicit return types and avoid propagating `Any` to callers.
4. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in `server_service.py`.
---
### FRONTEND
---
#### TASK F-1 — Wrap `SetupPage` API calls in a dedicated hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions from `src/api/` directly; all data fetching goes through hooks.
**Files affected:**
- `frontend/src/pages/SetupPage.tsx` — lines 24, 114, 179: imports `getSetupStatus` and `submitSetup` from `../api/setup` and calls them directly inside the component.
**What to do:**
1. Create `frontend/src/hooks/useSetup.ts` that encapsulates:
- Fetching setup status on mount (`{ isSetupComplete, loading, error }`).
- A `submitSetup(payload)` mutation that returns `{ submitting, submitError, submit }`.
2. Update `SetupPage.tsx` to use `useSetup` exclusively; remove all direct `api/setup` imports from the page.
---
#### TASK F-2 — Wrap `JailDetailPage` jail-control API calls in a hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
**Files affected:**
- `frontend/src/pages/JailDetailPage.tsx` — lines 3744, 262, 272, 285, 295: imports and directly calls `startJail`, `stopJail`, `setJailIdle`, `reloadJail` from `../api/jails`.
**What to do:**
1. Check whether `useJailDetail` or `useJails` already expose these control actions. If so, use those hook-provided callbacks instead of calling the API directly.
2. If they do not, add `start()`, `stop()`, `reload()`, `setIdle(idle: boolean)` actions to the appropriate hook (e.g., `useJailDetail`).
3. Remove all direct `startJail` / `stopJail` / `setJailIdle` / `reloadJail` API imports from the page.
4. The `ApiError` import may remain if it is used only for `instanceof` type-narrowing in error handlers, but prefer exposing an `error: ApiError | null` from the hook instead.
---
#### TASK F-3 — Wrap `MapPage` config API call in a hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
**Files affected:**
- `frontend/src/pages/MapPage.tsx` — line 34: imports `fetchMapColorThresholds` from `../api/config` and calls it in a `useEffect`.
**What to do:**
1. Create `frontend/src/hooks/useMapColorThresholds.ts` (or add the fetch to the existing `useMapData` hook if it is cohesive).
2. Replace the inline `useEffect` + `fetchMapColorThresholds` pattern in `MapPage` with the new hook call.
3. Remove the direct `api/config` import from the page.
---
#### TASK F-4 — Wrap `BlocklistsPage` preview API call in a hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
**Files affected:**
- `frontend/src/pages/BlocklistsPage.tsx` — line 54: imports `previewBlocklist` from `../api/blocklist`.
**What to do:**
1. Add a `previewBlocklist(url)` action to the existing `useBlocklists` hook (or create a `useBlocklistPreview` hook), returning `{ preview, previewing, previewError, runPreview }`.
2. Update `BlocklistsPage` to call the hook action instead of the raw API function.
3. Remove the direct `api/blocklist` import for `previewBlocklist` from the page.
---
#### TASK F-5 — Move all API calls out of `BannedIpsSection` into a hook
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions; all data must come via props or hooks invoked in the parent.
**Files affected:**
- `frontend/src/components/jail/BannedIpsSection.tsx` — imports and directly calls `fetchJailBannedIps` and `unbanIp` from `../../api/jails`.
**What to do:**
1. Create `frontend/src/hooks/useJailBannedIps.ts` with state `{ bannedIps, loading, error, page, totalPages, refetch }` and an `unban(ip)` action.
2. Invoke this hook in the parent page (`JailDetailPage`) and pass `bannedIps`, `loading`, `error`, `onUnban`, and pagination props down to `BannedIpsSection`.
3. Remove all `api/` imports from `BannedIpsSection.tsx`; the component receives everything through props.
4. Update `BannedIpsSection` tests to use props instead of mocking API calls directly.
---
#### TASK F-6 — Move all API calls out of config tab and dialog components into hooks
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions.
**Files affected (all in `frontend/src/components/config/`):**
- `FiltersTab.tsx` — calls `fetchFilters`, `fetchFilterFile`, `updateFilterFile` from `../../api/config` directly.
- `JailsTab.tsx` — calls multiple config API functions directly.
- `ActionsTab.tsx` — calls config API functions directly.
- `ExportTab.tsx` — calls multiple file-management API functions directly.
- `JailFilesTab.tsx` — calls API functions for jail file management.
- `ServerHealthSection.tsx` — calls `fetchFail2BanLog`, `fetchServiceStatus` from `../../api/config`.
- `CreateFilterDialog.tsx` — calls `createFilter` from `../../api/config`.
- `CreateJailDialog.tsx` — calls `createJailConfigFile` from `../../api/config`.
- `CreateActionDialog.tsx` — calls `createAction` from `../../api/config`.
- `ActivateJailDialog.tsx` — calls `activateJail`, `validateJailConfig` from `../../api/config`.
- `AssignFilterDialog.tsx` — calls `assignFilterToJail` from `../../api/config` and `fetchJails` from `../../api/jails`.
- `AssignActionDialog.tsx` — calls `assignActionToJail` from `../../api/config` and `fetchJails` from `../../api/jails`.
**What to do:**
For each component listed:
1. Identify or create the appropriate hook in `frontend/src/hooks/`. Group related concerns — for example, a single `useFiltersConfig` hook can cover fetch, update, and create actions for filters.
2. Move all `useEffect` + API call patterns from the component into the hook. The hook must return `{ data, loading, error, refetch, ...actions }`.
3. The component must receive data and action callbacks exclusively through props or a hook called in its closest page ancestor.
4. Remove all `../../api/` imports from the component files listed above.
5. Update or add unit tests for any new hooks created.
---
#### TASK F-7 — Move `SetupGuard` API call into a hook
**Violated rule:** Refactoring.md §3.2 — Components must not contain a `useEffect` that calls an API function.
**Files affected:**
- `frontend/src/components/SetupGuard.tsx` — line 12: imports `getSetupStatus` from `../api/setup`; lines 2836: calls it directly inside a `useEffect`.
**What to do:**
1. The `useSetup` hook created for TASK F-1 exposes setup-status fetching. Reuse it here, or extract the status-only slice into a `useSetupStatus()` hook that `SetupGuard` and `SetupPage` can both consume.
2. Replace the inline `useEffect` + `getSetupStatus` pattern in `SetupGuard` with a call to the hook.
3. Remove the direct `../api/setup` import from `SetupGuard.tsx`.
4. Update `SetupGuard` tests — they currently mock `../../api/setup` directly; update them to mock the hook instead.
**Dependency:** Can share hook infrastructure with TASK F-1.
---
#### TASK F-8 — Move `ServerTab` direct API calls into hooks
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions.
**Files affected:**
- `frontend/src/components/config/ServerTab.tsx`:
- lines 36-41: imports `fetchMapColorThresholds`, `updateMapColorThresholds`, `reloadConfig`, `restartFail2Ban` from `../../api/config` and calls each directly inside `useCallback`/`useEffect` handlers.
*Note: This component was inadvertently omitted from the TASK F-6 file list despite belonging to the same `components/config/` family.*
**What to do:**
1. The `fetchMapColorThresholds` / `updateMapColorThresholds` concern overlaps with TASK F-3 (`useMapColorThresholds` hook). Extend that hook or create a dedicated `useMapColorThresholdsConfig` hook that also exposes an `update(payload)` action.
2. Add `reload()` and `restart()` actions to a suitable config hook (e.g., a `useServerActions` hook or extend `useServerSettings` in `src/hooks/useConfig.ts`).
3. Replace all direct `reloadConfig()`, `restartFail2Ban()`, `fetchMapColorThresholds()`, and `updateMapColorThresholds()` calls in `ServerTab` with the hook-provided actions.
4. Remove all `../../api/config` imports for these four functions from `ServerTab.tsx`.
**Dependency:** Coordinate with TASK F-3 to avoid creating duplicate `useMapColorThresholds` hook logic.
---
#### TASK F-9 — Move `TimezoneProvider` API call into a hook
**Violated rule:** Refactoring.md §3.2 — A component (including a provider component) must not contain a `useEffect` that calls an API function directly; API calls belong in `src/hooks/`.
**Files affected:**
- `frontend/src/providers/TimezoneProvider.tsx` — line 20: imports `fetchTimezone` from `../api/setup`; lines 5762: calls it directly inside a `useCallback` that is invoked from `useEffect`.
**What to do:**
1. Create `frontend/src/hooks/useTimezoneData.ts` (or add to an existing setup-related hook) that fetches the timezone and returns `{ timezone, loading, error }`.
2. Call this hook inside `TimezoneProvider` and drive the context value from the hook's `timezone` output — removing the inline `fetchTimezone()` call.
3. Remove the direct `../api/setup` import from `TimezoneProvider.tsx`.
4. The hook may be reused in any future component that needs the configured timezone without going through the context.
---
#### TASK B-12 — Remove `Any` type annotations in `config_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/config_service.py` — several helper functions (`_ok`, `_to_dict`, `_ensure_list`, `_safe_get`, `_set`, `_set_global`) use `Any` for inputs/outputs.
**What to do:**
1. Define typed aliases for the fail2ban client response and command shapes (e.g., `Fail2BanResponse = tuple[int, object | None]`, `Fail2BanCommand = list[str | int | None]`).
2. Replace `Any` in helper signatures with the new aliases (and use `object`/`str`/`int` where appropriate).
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-13 — Remove `Any` type annotations in `jail_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/jail_service.py` — helper utilities (`_ok`, `_to_dict`, `_ensure_list`, `_safe_get`, etc.) use `Any` for raw fail2ban responses and command parameters.
**What to do:**
1. Define typed aliases for fail2ban response and command shapes (e.g., `Fail2BanResponse`, `Fail2BanCommand`).
2. Update helper function signatures to use the new types instead of `Any`.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-14 — Remove `Any` type annotations in `health_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/health_service.py` — helper functions `_ok` and `_to_dict` and their callers currently use `Any`.
**What to do:**
1. Define typed aliases for fail2ban responses (e.g. `Fail2BanResponse = tuple[int, object | None]`).
2. Update `_ok`, `_to_dict`, and any helper usage sites to use concrete types instead of `Any`.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-15 — Remove `Any` type annotations in `blocklist_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/blocklist_service.py` — helper `_row_to_source()` and other internal functions currently use `Any`.
**What to do:**
1. Replace `Any` with precise types for repository row dictionaries (e.g. `dict[str, object]` or a dedicated `BlocklistSourceRow` TypedDict).
2. Update helper signatures and any call sites accordingly.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-16 — Remove `Any` type annotations in `import_log_repo.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/repositories/import_log_repo.py` — returns `dict[str, Any]` and accepts `list[Any]` parameters.
**What to do:**
1. Define a typed row model (e.g. `ImportLogRow = TypedDict[...]`) or a Pydantic model for import log entries.
2. Update public function signatures to return typed structures instead of `dict[str, Any]` and to accept properly typed query parameters.
3. Update callers (e.g. `routers/blocklist.py` and `services/blocklist_service.py`) to work with the new types.
4. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-17 — Remove `Any` type annotations in `config_file_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/config_file_service.py` — internal helpers (`_to_dict_inner`, `_ok`, etc.) use `Any` for fail2ban response objects.
**What to do:**
1. Introduce typed aliases for fail2ban command/response shapes (e.g. `Fail2BanResponse`, `Fail2BanCommand`).
2. Replace `Any` in helper function signatures and return types with these aliases.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-18 — Remove `Any` type annotations in `fail2ban_client.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/utils/fail2ban_client.py` — the public client interface uses `Any` for command and response types.
**What to do:**
1. Define clear type aliases such as `Fail2BanCommand = list[str | int | bool | None]` and `Fail2BanResponse = object` (or a more specific union of expected response shapes).
2. Update `_send_command_sync`, `_coerce_command_token`, and `Fail2BanClient.send` signatures to use these aliases.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-19 — Remove `Any` annotations from background tasks
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/tasks/health_check.py` — uses `app: Any` and `last_activation: dict[str, Any] | None`.
- `backend/app/tasks/geo_re_resolve.py` — uses `app: Any`.
**What to do:**
1. Define a typed model for the shared application state (e.g., a `TypedDict` or `Protocol`) that includes the expected properties on `app.state` (e.g., `settings`, `db`, `server_status`, `last_activation`, `pending_recovery`).
2. Change task callbacks to accept `FastAPI` (or the typed app) instead of `Any`.
3. Replace `dict[str, Any]` with a lean typed record (e.g., a `TypedDict` or a small `@dataclass`) for `last_activation`.
4. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in these files.
---
#### TASK B-20 — Remove `type: ignore` in `dependencies.get_settings`
**Violated rule:** Backend-Development.md §1 — Avoid `Any` and ignored type errors.
**Files affected:**
- `backend/app/dependencies.py` — `get_settings` currently uses `# type: ignore[no-any-return]`.
**What to do:**
1. Introduce a typed model (e.g., `TypedDict` or `Protocol`) for `app.state` to declare `settings: Settings` and other shared state properties.
2. Update `get_settings` (and any other helpers that read from `app.state`) so the return type is inferred as `Settings` without needing a `type: ignore` comment.
3. Run `mypy --strict` or `pyright` to confirm the type ignore is no longer needed.

View File

@@ -1 +1,50 @@
"""BanGUI backend application package."""
"""BanGUI backend application package.
This package exposes the application version based on the project metadata.
"""
from __future__ import annotations
from pathlib import Path
from typing import Final
import importlib.metadata
import tomllib
PACKAGE_NAME: Final[str] = "bangui-backend"
def _read_pyproject_version() -> str:
"""Read the project version from ``pyproject.toml``.
This is used as a fallback when the package metadata is not available (e.g.
when running directly from a source checkout without installing the package).
"""
project_root = Path(__file__).resolve().parents[1]
pyproject_path = project_root / "pyproject.toml"
if not pyproject_path.exists():
raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
return str(data["project"]["version"])
def _read_version() -> str:
"""Return the current package version.
Prefer the project metadata in ``pyproject.toml`` when available, since this
is the single source of truth for local development and is kept in sync with
the frontend and Docker release version.
When running from an installed distribution where the ``pyproject.toml``
is not available, fall back to installed package metadata.
"""
try:
return _read_pyproject_version()
except FileNotFoundError:
return importlib.metadata.version(PACKAGE_NAME)
__version__ = _read_version()

View File

@@ -31,6 +31,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app import __version__
from app.config import Settings, get_settings
from app.db import init_db
from app.routers import (
@@ -365,7 +366,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app: FastAPI = FastAPI(
title="BanGUI",
description="Web interface for monitoring, managing, and configuring fail2ban.",
version="0.1.0",
version=__version__,
lifespan=_lifespan,
)

View File

@@ -23,7 +23,7 @@ if TYPE_CHECKING:
from fastapi import APIRouter, HTTPException, Query, Request
from app.dependencies import AuthDep
from app.models.ban import TimeRange
from app.models.ban import BanOrigin, TimeRange
from app.models.history import HistoryListResponse, IpDetailResponse
from app.services import geo_service, history_service
@@ -52,6 +52,10 @@ async def get_history(
default=None,
description="Restrict results to IPs matching this prefix.",
),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(
default=_DEFAULT_PAGE_SIZE,
@@ -89,6 +93,7 @@ async def get_history(
range_=range,
jail=jail,
ip_filter=ip,
origin=origin,
page=page,
page_size=page_size,
geo_enricher=_enricher,

View File

@@ -368,8 +368,9 @@ 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)
# Fail2ban does not support changing the log monitoring backend at runtime.
# The configuration value is retained for read/display purposes but must not
# be applied via the socket API.
if update.log_encoding is not None:
await _set("logencoding", update.log_encoding)
if update.prefregex is not None:

View File

@@ -16,7 +16,7 @@ from typing import Any
import aiosqlite
import structlog
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
from app.models.ban import BLOCKLIST_JAIL, BanOrigin, TIME_RANGE_SECONDS, TimeRange
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
@@ -58,6 +58,7 @@ async def list_history(
*,
range_: TimeRange | None = None,
jail: str | None = None,
origin: BanOrigin | None = None,
ip_filter: str | None = None,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
@@ -73,6 +74,8 @@ async def list_history(
socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset. ``None`` means all-time (no time filter).
jail: If given, restrict results to bans from this jail.
origin: Optional origin filter — ``"blocklist"`` restricts results to
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
ip_filter: If given, restrict results to bans for this exact IP
(or a prefix — the query uses ``LIKE ip_filter%``).
page: 1-based page number (default: ``1``).
@@ -99,6 +102,14 @@ async def list_history(
wheres.append("jail = ?")
params.append(jail)
if origin is not None:
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if ip_filter is not None:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")

View File

@@ -49,7 +49,7 @@ logpath = /dev/null
backend = auto
maxretry = 1
findtime = 1d
bantime = 1w
bantime = 86400
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
"""

View File

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

View File

@@ -213,6 +213,18 @@ class TestHistoryList:
_args, kwargs = mock_fn.call_args
assert kwargs.get("range_") == "7d"
async def test_forwards_origin_filter(self, history_client: AsyncClient) -> None:
"""The ``origin`` 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?origin=blocklist")
_args, kwargs = mock_fn.call_args
assert kwargs.get("origin") == "blocklist"
async def test_empty_result(self, history_client: AsyncClient) -> None:
"""An empty history returns items=[] and total=0."""
with patch(

View File

@@ -256,6 +256,27 @@ class TestUpdateJailConfig:
assert "bantime" in keys
assert "maxretry" in keys
async def test_ignores_backend_field(self) -> None:
"""update_jail_config does not send a set command for backend."""
sent_commands: list[list[Any]] = []
async def _send(command: list[Any]) -> Any:
sent_commands.append(command)
return (0, "OK")
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
from app.models.config import JailConfigUpdate
update = JailConfigUpdate(backend="polling")
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
await config_service.update_jail_config(_SOCKET, "sshd", update)
keys = [cmd[2] for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
assert "backend" not in keys
async def test_raises_validation_error_on_bad_regex(self) -> None:
"""update_jail_config raises ConfigValidationError for invalid regex."""
from app.models.config import JailConfigUpdate

View File

@@ -65,6 +65,10 @@ class TestEnsureJailConfigs:
content = _read(jail_d, conf_file)
assert "enabled = false" in content
# Blocklist-import jail must have a 24-hour ban time
blocklist_conf = _read(jail_d, _BLOCKLIST_CONF)
assert "bantime = 86400" in blocklist_conf
# .local files must set enabled = true and nothing else
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
content = _read(jail_d, local_file)

View File

@@ -1,12 +1,12 @@
{
"name": "bangui-frontend",
"version": "0.1.0",
"version": "0.9.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bangui-frontend",
"version": "0.1.0",
"version": "0.9.4",
"dependencies": {
"@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257",

View File

@@ -1,7 +1,7 @@
{
"name": "bangui-frontend",
"private": true,
"version": "0.9.4",
"version": "0.9.8",
"description": "BanGUI frontend — fail2ban web management interface",
"type": "module",
"scripts": {

View File

@@ -18,6 +18,7 @@ export async function fetchHistory(
): Promise<HistoryListResponse> {
const params = new URLSearchParams();
if (query.range) params.set("range", query.range);
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.page !== undefined) params.set("page", String(query.page));

View File

@@ -216,7 +216,6 @@ function JailConfigDetail({
ignore_regex: ignoreRegex,
date_pattern: datePattern !== "" ? datePattern : null,
dns_mode: dnsMode,
backend,
log_encoding: logEncoding,
prefregex: prefRegex !== "" ? prefRegex : null,
bantime_escalation: {
@@ -231,7 +230,7 @@ function JailConfigDetail({
}),
[
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor,
dnsMode, logEncoding, prefRegex, escEnabled, escFactor,
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
jail.ban_time, jail.find_time, jail.max_retry,
],
@@ -758,7 +757,12 @@ function InactiveJailDetail({
*
* @returns JSX element.
*/
export function JailsTab(): React.JSX.Element {
interface JailsTabProps {
/** Jail name to pre-select when the component mounts. */
initialJail?: string;
}
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail } =
useJailConfigs();
@@ -819,6 +823,13 @@ export function JailsTab(): React.JSX.Element {
return [...activeItems, ...inactiveItems];
}, [jails, inactiveJails]);
useEffect(() => {
if (!initialJail || selectedName) return;
if (listItems.some((item) => item.name === initialJail)) {
setSelectedName(initialJail);
}
}, [initialJail, listItems, selectedName]);
const activeJailMap = useMemo(
() => new Map(jails.map((j) => [j.name, j])),
[jails],

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { JailsTab } from "../JailsTab";
import type { JailConfig } from "../../../types/config";
import { useAutoSave } from "../../../hooks/useAutoSave";
import { useJailConfigs } from "../../../hooks/useConfig";
import { useConfigActiveStatus } from "../../../hooks/useConfigActiveStatus";
vi.mock("../../../hooks/useAutoSave");
vi.mock("../../../hooks/useConfig");
vi.mock("../../../hooks/useConfigActiveStatus");
vi.mock("../../../api/config", () => ({
fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [] }),
deactivateJail: vi.fn(),
deleteJailLocalOverride: vi.fn(),
addLogPath: vi.fn(),
deleteLogPath: vi.fn(),
fetchJailConfigFileContent: vi.fn(),
updateJailConfigFile: vi.fn(),
validateJailConfig: vi.fn(),
}));
const mockUseAutoSave = vi.mocked(useAutoSave);
const mockUseJailConfigs = vi.mocked(useJailConfigs);
const mockUseConfigActiveStatus = vi.mocked(useConfigActiveStatus);
const basicJail: JailConfig = {
name: "sshd",
ban_time: 600,
max_retry: 5,
find_time: 600,
fail_regex: [],
ignore_regex: [],
log_paths: [],
date_pattern: null,
log_encoding: "auto",
backend: "polling",
use_dns: "warn",
prefregex: "",
actions: [],
bantime_escalation: null,
};
describe("JailsTab", () => {
it("does not include backend in auto-save payload", () => {
const autoSavePayloads: Array<Record<string, unknown>> = [];
mockUseAutoSave.mockImplementation((value) => {
autoSavePayloads.push(value as Record<string, unknown>);
return { status: "idle", errorText: null, retry: vi.fn() };
});
mockUseJailConfigs.mockReturnValue({
jails: [basicJail],
total: 1,
loading: false,
error: null,
refresh: vi.fn(),
updateJail: vi.fn(),
reloadAll: vi.fn(),
});
mockUseConfigActiveStatus.mockReturnValue({ activeJails: new Set<string>() });
render(
<FluentProvider theme={webLightTheme}>
<JailsTab initialJail="sshd" />
</FluentProvider>,
);
expect(autoSavePayloads.length).toBeGreaterThan(0);
const lastPayload = autoSavePayloads[autoSavePayloads.length - 1];
expect(lastPayload).not.toHaveProperty("backend");
});
});

View File

@@ -13,7 +13,8 @@
* Export — raw file editors for jail, filter, and action files
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
import {
ActionsTab,
@@ -58,8 +59,16 @@ type TabValue =
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
const location = useLocation();
const [tab, setTab] = useState<TabValue>("jails");
useEffect(() => {
const state = location.state as { tab?: string; jail?: string } | null;
if (state?.tab === "jails") {
setTab("jails");
}
}, [location.state]);
return (
<div className={styles.page}>
<div className={styles.header}>
@@ -86,7 +95,11 @@ export function ConfigPage(): React.JSX.Element {
</TabList>
<div className={styles.tabContent} key={tab}>
{tab === "jails" && <JailsTab />}
{tab === "jails" && (
<JailsTab
initialJail={(location.state as { jail?: string } | null)?.jail}
/>
)}
{tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />}
{tab === "server" && <ServerTab />}

View File

@@ -19,7 +19,6 @@ import {
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
Table,
TableBody,
@@ -42,8 +41,10 @@ import {
ChevronLeftRegular,
ChevronRightRegular,
} from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { useHistory, useIpHistory } from "../hooks/useHistory";
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
import type { BanOriginFilter } from "../types/ban";
// ---------------------------------------------------------------------------
// Constants
@@ -54,13 +55,6 @@ const HIGH_BAN_THRESHOLD = 5;
const PAGE_SIZE = 50;
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
{ label: "Last 24 hours", value: "24h" },
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
{ label: "Last 365 days", value: "365d" },
];
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
@@ -381,7 +375,8 @@ export function HistoryPage(): React.JSX.Element {
const styles = useStyles();
// Filter state
const [range, setRange] = useState<TimeRange | undefined>(undefined);
const [range, setRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState("");
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
@@ -397,11 +392,12 @@ export function HistoryPage(): React.JSX.Element {
const applyFilters = useCallback((): void => {
setAppliedQuery({
range: range,
origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined,
page_size: PAGE_SIZE,
});
}, [range, jailFilter, ipFilter]);
}, [range, originFilter, jailFilter, ipFilter]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
@@ -452,24 +448,16 @@ export function HistoryPage(): React.JSX.Element {
{/* Filter bar */}
{/* ---------------------------------------------------------------- */}
<div className={styles.filterRow}>
<div className={styles.filterLabel}>
<Text size={200}>Time range</Text>
<Select
aria-label="Time range"
value={range ?? ""}
onChange={(_ev, data): void => {
setRange(data.value === "" ? undefined : (data.value as TimeRange));
}}
size="small"
>
<option value="">All time</option>
{TIME_RANGE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</div>
<DashboardFilterBar
timeRange={range}
onTimeRangeChange={(value) => {
setRange(value);
}}
originFilter={originFilter}
onOriginFilterChange={(value) => {
setOriginFilter(value);
}}
/>
<div className={styles.filterLabel}>
<Text size={200}>Jail</Text>
@@ -506,7 +494,8 @@ export function HistoryPage(): React.JSX.Element {
appearance="subtle"
size="small"
onClick={(): void => {
setRange(undefined);
setRange("24h");
setOriginFilter("all");
setJailFilter("");
setIpFilter("");
setAppliedQuery({ page_size: PAGE_SIZE });

View File

@@ -9,7 +9,7 @@
* geo-location details.
*/
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Badge,
Button,
@@ -42,7 +42,7 @@ import {
SearchRegular,
StopRegular,
} from "@fluentui/react-icons";
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { JailSummary } from "../types/jail";
import { ApiError } from "../api/client";
@@ -151,77 +151,88 @@ function fmtSeconds(s: number): string {
return `${String(Math.round(s / 3600))}h`;
}
// ---------------------------------------------------------------------------
// Jail overview columns
// ---------------------------------------------------------------------------
const jailColumns: TableColumnDefinition<JailSummary>[] = [
createTableColumn<JailSummary>({
columnId: "name",
renderHeaderCell: () => "Jail",
renderCell: (j) => (
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
{j.name}
</Text>
</Link>
),
}),
createTableColumn<JailSummary>({
columnId: "status",
renderHeaderCell: () => "Status",
renderCell: (j) => {
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
return <Badge appearance="filled" color="success">running</Badge>;
},
}),
createTableColumn<JailSummary>({
columnId: "backend",
renderHeaderCell: () => "Backend",
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banned",
renderHeaderCell: () => "Banned",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "failed",
renderHeaderCell: () => "Failed",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "findTime",
renderHeaderCell: () => "Find Time",
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "maxRetry",
renderHeaderCell: () => "Max Retry",
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
}),
];
// ---------------------------------------------------------------------------
// Sub-component: Jail overview section
// ---------------------------------------------------------------------------
function JailOverviewSection(): React.JSX.Element {
const styles = useStyles();
const navigate = useNavigate();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails();
const [opError, setOpError] = useState<string | null>(null);
const jailColumns = useMemo<TableColumnDefinition<JailSummary>[]>(
() => [
createTableColumn<JailSummary>({
columnId: "name",
renderHeaderCell: () => "Jail",
renderCell: (j) => (
<Button
appearance="transparent"
size="small"
style={{ padding: 0, minWidth: 0, justifyContent: "flex-start" }}
onClick={() =>
navigate("/config", {
state: { tab: "jails", jail: j.name },
})
}
>
<Text
style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}
>
{j.name}
</Text>
</Button>
),
}),
createTableColumn<JailSummary>({
columnId: "status",
renderHeaderCell: () => "Status",
renderCell: (j) => {
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
return <Badge appearance="filled" color="success">running</Badge>;
},
}),
createTableColumn<JailSummary>({
columnId: "backend",
renderHeaderCell: () => "Backend",
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banned",
renderHeaderCell: () => "Banned",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "failed",
renderHeaderCell: () => "Failed",
renderCell: (j) => (
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
),
}),
createTableColumn<JailSummary>({
columnId: "findTime",
renderHeaderCell: () => "Find Time",
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "maxRetry",
renderHeaderCell: () => "Max Retry",
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
}),
],
[navigate],
);
const handle = (fn: () => Promise<void>): void => {
setOpError(null);
fn().catch((err: unknown) => {

View File

@@ -12,7 +12,6 @@ import {
Button,
MessageBar,
MessageBarBody,
Select,
Spinner,
Table,
TableBody,
@@ -22,19 +21,17 @@ import {
TableHeaderCell,
TableRow,
Text,
Toolbar,
ToolbarButton,
Tooltip,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { WorldMap } from "../components/WorldMap";
import { useMapData } from "../hooks/useMapData";
import { fetchMapColorThresholds } from "../api/config";
import type { TimeRange } from "../types/map";
import type { BanOriginFilter } from "../types/ban";
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
// ---------------------------------------------------------------------------
// Styles
@@ -56,34 +53,23 @@ const useStyles = makeStyles({
flexWrap: "wrap",
gap: tokens.spacingHorizontalM,
},
filterBar: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
background: tokens.colorNeutralBackground3,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke2}`,
},
tableWrapper: {
overflow: "auto",
maxHeight: "420px",
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
},
filterBar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
padding: tokens.spacingVerticalS,
borderRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorNeutralBackground2,
},
});
// ---------------------------------------------------------------------------
// Time-range options
// ---------------------------------------------------------------------------
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
{ label: "Last 24 hours", value: "24h" },
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
{ label: "Last 365 days", value: "365d" },
];
// ---------------------------------------------------------------------------
// MapPage
// ---------------------------------------------------------------------------
@@ -136,41 +122,20 @@ export function MapPage(): React.JSX.Element {
World Map
</Text>
<Toolbar size="small">
<Select
aria-label="Time range"
value={range}
onChange={(_ev, data): void => {
setRange(data.value as TimeRange);
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
<DashboardFilterBar
timeRange={range}
onTimeRangeChange={(value) => {
setRange(value);
setSelectedCountry(null);
}}
size="small"
>
{TIME_RANGE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
{/* Origin filter */}
<Select
aria-label="Origin filter"
value={originFilter}
onChange={(_ev, data): void => {
setOriginFilter(data.value as BanOriginFilter);
originFilter={originFilter}
onOriginFilterChange={(value) => {
setOriginFilter(value);
setSelectedCountry(null);
}}
size="small"
>
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
<option key={f} value={f}>
{BAN_ORIGIN_FILTER_LABELS[f]}
</option>
))}
</Select>
<ToolbarButton
/>
<Button
icon={<ArrowCounterclockwiseRegular />}
onClick={(): void => {
refresh();
@@ -178,7 +143,7 @@ export function MapPage(): React.JSX.Element {
disabled={loading}
title="Refresh"
/>
</Toolbar>
</div>
</div>
{/* ---------------------------------------------------------------- */}

View File

@@ -6,7 +6,11 @@ import { ConfigPage } from "../ConfigPage";
// Mock all tab components to avoid deep render trees and API calls.
vi.mock("../../components/config", () => ({
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
JailsTab: ({ initialJail }: { initialJail?: string }) => (
<div data-testid="jails-tab" data-initial-jail={initialJail}>
JailsTab
</div>
),
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
@@ -53,4 +57,22 @@ describe("ConfigPage", () => {
renderPage();
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
});
it("selects the Jails tab based on location state", () => {
render(
<MemoryRouter
initialEntries={[
{ pathname: "/config", state: { tab: "jails", jail: "sshd" } },
]}
>
<FluentProvider theme={webLightTheme}>
<ConfigPage />
</FluentProvider>
</MemoryRouter>,
);
const jailsTab = screen.getByTestId("jails-tab");
expect(jailsTab).toBeInTheDocument();
expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd");
});
});

View File

@@ -0,0 +1,58 @@
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 { HistoryPage } from "../HistoryPage";
let lastQuery: Record<string, unknown> | null = null;
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
lastQuery = query;
return {
items: [],
total: 0,
page: 1,
loading: false,
error: null,
setPage: vi.fn(),
refresh: vi.fn(),
};
});
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", () => ({
WorldMap: () => <div data-testid="world-map" />,
}));
vi.mock("../api/config", () => ({
fetchMapColorThresholds: async () => ({
threshold_low: 10,
threshold_medium: 50,
threshold_high: 100,
}),
}));
describe("HistoryPage", () => {
it("renders DashboardFilterBar and applies origin+range filters", async () => {
const user = userEvent.setup();
render(
<FluentProvider theme={webLightTheme}>
<HistoryPage />
</FluentProvider>,
);
// Initial load should include the default query.
expect(lastQuery).toEqual({ page_size: 50 });
// Change the time-range and origin filter, then apply.
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 }));
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
});
});

View File

@@ -0,0 +1,74 @@
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 { MemoryRouter } from "react-router-dom";
import { JailsPage } from "../JailsPage";
import type { JailSummary } from "../../types/jail";
const mockNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = (await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
)) as unknown as Record<string, unknown>;
return {
...actual,
useNavigate: () => mockNavigate,
};
});
vi.mock("../hooks/useJails", () => ({
useJails: () => ({
jails: [
{
name: "sshd",
enabled: true,
running: true,
idle: false,
backend: "systemd",
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: {
currently_banned: 1,
total_banned: 10,
currently_failed: 0,
total_failed: 0,
},
},
] as JailSummary[],
total: 1,
loading: false,
error: null,
refresh: vi.fn(),
startJail: vi.fn().mockResolvedValue(undefined),
stopJail: vi.fn().mockResolvedValue(undefined),
setIdle: vi.fn().mockResolvedValue(undefined),
reloadJail: vi.fn().mockResolvedValue(undefined),
reloadAll: vi.fn().mockResolvedValue(undefined),
}),
}));
function renderPage() {
return render(
<MemoryRouter>
<FluentProvider theme={webLightTheme}>
<JailsPage />
</FluentProvider>
</MemoryRouter>,
);
}
describe("JailsPage", () => {
it("navigates to Configuration → Jails when a jail is clicked", async () => {
renderPage();
const user = userEvent.setup();
await user.click(screen.getByText("sshd"));
expect(mockNavigate).toHaveBeenCalledWith("/config", {
state: { tab: "jails", jail: "sshd" },
});
});
});

View File

@@ -0,0 +1,58 @@
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 { 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 {
countries: {},
countryNames: {},
bans: [],
total: 0,
loading: false,
error: null,
refresh: vi.fn(),
};
});
vi.mock("../hooks/useMapData", () => ({
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
}));
vi.mock("../api/config", async () => ({
fetchMapColorThresholds: mockFetchMapColorThresholds,
}));
vi.mock("../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />,
}));
describe("MapPage", () => {
it("renders DashboardFilterBar and updates data when filters change", async () => {
const user = userEvent.setup();
render(
<FluentProvider theme={webLightTheme}>
<MapPage />
</FluentProvider>,
);
// Initial load should call useMapData with default filters.
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
expect(lastArgs.range).toBe("7d");
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
expect(lastArgs.origin).toBe("blocklist");
});
});

View File

@@ -50,8 +50,11 @@ export interface IpDetailResponse {
}
/** Query parameters supported by GET /api/history */
import type { BanOriginFilter } from "./ban";
export interface HistoryQuery {
range?: TimeRange;
origin?: BanOriginFilter;
jail?: string;
ip?: string;
page?: number;

10
pytest.ini Normal file
View File

@@ -0,0 +1,10 @@
[pytest]
# Ensure pytest-asyncio is in auto mode for async tests without explicit markers.
asyncio_mode = auto
# Run the backend test suite from the repository root.
testpaths = backend/tests
pythonpath = backend
# Keep coverage output consistent with backend/pyproject.toml settings.
addopts = --cov=backend/app --cov-report=term-missing