18 Commits

Author SHA1 Message Date
376c13370d chore: release v0.9.10 2026-03-20 13:32:26 +01:00
fb6d0e588f chore: release v0.9.9 2026-03-19 20:13:24 +01:00
e44caccb3c chore(release): push git refs after successful container build 2026-03-19 19:52:17 +01:00
15e4a5434e Display BanGUI version in dashboard and server config UI 2026-03-19 19:45:43 +01:00
1cc9968d31 Expose BanGUI version in API responses (dashboard + config) 2026-03-19 19:19:42 +01:00
80a6bac33e Sync backend/frontend versions to Docker/VERSION and read version from it 2026-03-19 19:13:38 +01:00
133ab2e82c Add hover tooltip to WorldMap and update task list 2026-03-19 19:10:44 +01:00
60f2f35b25 backup 2026-03-17 18:30:59 +01:00
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
48 changed files with 1066 additions and 652 deletions

View File

@@ -1 +1 @@
v0.9.4 v0.9.10

View File

@@ -18,8 +18,8 @@ logpath = /dev/null
backend = auto backend = auto
maxretry = 1 maxretry = 1
findtime = 1d findtime = 1d
# Block imported IPs for one week. # Block imported IPs for 24 hours.
bantime = 1w bantime = 86400
# Never ban the Docker bridge network or localhost. # Never ban the Docker bridge network or localhost.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 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 " Tag : ${TAG}"
echo "============================================" echo "============================================"
if [[ "${ENGINE}" == "podman" ]]; then log "Logging in to ${REGISTRY}"
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then "${ENGINE}" login "${REGISTRY}"
err "Not logged in. Run:\n podman login ${REGISTRY}"
fi
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Build # Build

View File

@@ -69,18 +69,23 @@ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_P
echo "frontend/package.json version updated → ${FRONT_VERSION}" echo "frontend/package.json version updated → ${FRONT_VERSION}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Git tag # Git tag (local only; push after container build)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
cd "${SCRIPT_DIR}/.." cd "${SCRIPT_DIR}/.."
git add Docker/VERSION frontend/package.json git add Docker/VERSION frontend/package.json
git commit -m "chore: release ${NEW_TAG}" git commit -m "chore: release ${NEW_TAG}"
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}" git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
git push origin HEAD echo "Local git commit + tag ${NEW_TAG} created."
git push origin "${NEW_TAG}"
echo "Git tag ${NEW_TAG} created and pushed."
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Push # Push containers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}" bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh" bash "${SCRIPT_DIR}/push.sh"
# ---------------------------------------------------------------------------
# Push git commits & tag
# ---------------------------------------------------------------------------
git push origin HEAD
git push origin "${NEW_TAG}"
echo "Git commit and tag ${NEW_TAG} pushed."

View File

@@ -12,496 +12,211 @@ This document breaks the entire BanGUI project into development stages, ordered
--- ---
### BACKEND ## Feature: Worldmap Country Tooltip
> **2026-03-17**
> The world map on the Map page colours each country by ban count but provides no immediate information on hover — the user must click a country to see its name in the filter bar below, and must read the small SVG count label to learn the number of bans.
>
> Goal: show a lightweight floating tooltip whenever the pointer enters a country, displaying the country's display name and its current ban count, so the information is accessible without a click.
--- ---
#### TASK B-1 — Create a `fail2ban_db` repository for direct fail2ban database queries ### Task WM-1 — Show country name and ban count tooltip on map hover
**Violated rule:** Refactoring.md §2.2 — Services must not perform direct `aiosqlite` calls; go through a repository. **Scope:** `frontend/src/components/WorldMap.tsx`, `frontend/src/pages/MapPage.tsx`
**Files affected:** `countryNames` (ISO alpha-2 → display name) is already available in `MapPage` from `useMapData` but is not forwarded to `WorldMap`. The map component itself tracks no hover state. This task adds pointer-event handlers to each country `<g>` element, tracks the hovered country in local state together with the last known mouse coordinates, and renders a positionned HTML tooltip `<div>` on top of the SVG.
- `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.
**What to do:** **Implementation steps:**
1. Create `backend/app/repositories/fail2ban_db_repo.py`. 1. **Extend `WorldMapProps` and `GeoLayerProps`** in `WorldMap.tsx`:
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). - Add `countryNames?: Record<string, string>` to `WorldMapProps` (optional — falls back to the ISO alpha-2 code when absent).
- `get_currently_banned(db_path, jail_filter, since) -> list[BanRecord]` - Thread it through `GeoLayer` the same way the threshold props are already threaded.
- `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.
--- 2. **Add hover state to `GeoLayer`** — declare:
```ts
#### TASK B-2 — Remove direct SQL query from `routers/geo.py` const [tooltip, setTooltip] = useState<{
cc: string;
**Violated rule:** Refactoring.md §2.1 — Routers must contain zero business logic; no SQL or repository imports. count: number;
name: string;
**Files affected:** x: number;
- `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. y: number;
} | null>(null);
**What to do:**
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.
---
#### TASK B-3 — Remove repository import from `routers/blocklist.py`
**Violated rule:** Refactoring.md §2.1 — Routers must not import from repositories; all data access must go through services.
**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.
**What to do:**
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.
---
#### 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). On each country `<g>` element add:
3. Replace `geo_map: dict[str, Any]` with `geo_map: dict[str, GeoInfo]` (both occurrences). - `onMouseEnter` — set `tooltip` with the country code, count, display name (from `countryNames`, falling back to the alpha-2 code), and mouse page coordinates (`e.clientX`, `e.clientY`).
4. Replace the inner `_safe_lookup` return type `tuple[str, Any]` with `tuple[str, GeoInfo | None]`. - `onMouseMove` — update only the `x`/`y` in the existing tooltip (keep name/count stable).
5. Run `mypy --strict` or `pyright` to confirm zero remaining type errors in this file. - `onMouseLeave` — set `tooltip` to `null`.
Skip setting the tooltip for countries where `cc === null` (no ISO mapping available) but keep `onMouseLeave` so re-entering after leaving from an unmapped border still clears the state.
3. **Render the tooltip inside `GeoLayer`** — because `GeoLayer` is rendered inside `ComposableMap` which is inside `mapWrapper`, the tooltip div cannot be positioned relative to the map wrapper from here (the SVG clip/transform would offset it). Instead, use a React **portal** (`ReactDOM.createPortal`) to mount the tooltip directly on `document.body` so it sits in the root stacking context and can be positioned with `position: fixed` using the raw `clientX`/`clientY` coordinates.
Tooltip structure (styled with a new `makeStyles` class `tooltip` in `WorldMap.tsx`):
```tsx
{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,
)}
```
4. **Tooltip styles** — add three new classes to the `makeStyles` call in `WorldMap.tsx`:
```ts
tooltip: {
position: "fixed",
zIndex: 9999,
pointerEvents: "none",
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
borderRadius: tokens.borderRadiusSmall,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXXS,
boxShadow: tokens.shadow4,
},
tooltipCountry: {
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1,
},
tooltipCount: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground2,
},
```
5. **Pass `countryNames` from `MapPage`** — in `MapPage.tsx`, add the `countryNames` prop to the existing `<WorldMap …>` JSX:
```tsx
<WorldMap
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry}
/>
```
6. **Countries with zero bans** — the tooltip should still appear when the user hovers over a country with `0` bans (showing the name and "0 bans"), so users know the country is tracked but has no bans. Do not suppress the tooltip for zero-count countries.
**Acceptance criteria:**
- Moving the pointer over any mapped country on the Map page shows a floating tooltip within 0 ms (synchronous state update) containing the country's full display name (e.g. `Germany`) on the first line and the ban count (e.g. `42 bans` or `0 bans`) on the second line.
- Moving the pointer off a country hides the tooltip immediately.
- The tooltip follows the pointer as it moves within a country's borders.
- Clicking a country still selects/deselects it exactly as before; the tooltip does not interfere with the click handler.
- The tooltip is not interactive (`pointerEvents: none`) and does not steal focus from the map.
- `tsc --noEmit` produces no new errors.
**Status:** ✅ Completed (2026-03-19)
--- ---
#### TASK B-8 — Remove `print()` from `geo_service.py` docstring example ## Feature: Global Unique BanGUI Version
**Violated rule:** Refactoring.md §4 / Backend-Development.md §2 — Never use `print()` in production code; use `structlog`. > **2026-03-17**
> The BanGUI application version is currently scattered across three independent files that are not kept in sync:
**Files affected:** > - `Docker/VERSION` — `v0.9.8` (release artifact, written by the release script)
- `backend/app/services/geo_service.py` — line 33: `print(info.country_code) # "DE"` appears inside a module-level docstring usage example. > - `frontend/package.json` — `0.9.8`
> - `backend/pyproject.toml` — `0.9.4` ← **out of sync**
**What to do:** >
> Additionally the BanGUI version is only shown in the sidebar footer (`MainLayout.tsx`). Neither the Dashboard nor the Configuration → Server view exposes the BanGUI application version, only the fail2ban daemon version.
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"`. >
> Goal: one authoritative version string, propagated automatically to all layers, and displayed consistently on both the Dashboard and the Configuration → Server page.
--- ---
#### TASK B-9 — Remove direct SQL from `main.py` lifespan into `geo_service` ### Task GV-1 — Establish a single source of truth for the BanGUI version
**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). **Scope:** `Docker/VERSION`, `backend/pyproject.toml`, `frontend/package.json`, `backend/app/__init__.py`
**Files affected:** `Docker/VERSION` is already the file written by the release script (`Docker/release.sh`) and is therefore the natural single source of truth.
- `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. Sync the two package manifests to the current release version:
- Set `version` in `backend/pyproject.toml` to `0.9.8` (strip the leading `v` that `Docker/VERSION` contains).
- `frontend/package.json` is already `0.9.8` — no change needed.
2. Make the backend read its version **directly from `Docker/VERSION`** at import time instead of from `pyproject.toml`, so a future release-script bump of `Docker/VERSION` is sufficient. Update `_read_pyproject_version()` in `backend/app/__init__.py`:
- Add a new helper `_read_docker_version() -> str` that resolves `Docker/VERSION` relative to the repository root (two `parents` above `backend/app/`), strips the leading `v` and whitespace, and returns the bare semver string.
- Change `_read_version()` to try `_read_docker_version()` first, then fall back to `_read_pyproject_version()`, then `importlib.metadata`.
3. Make the frontend read its version from `Docker/VERSION` at build time. In `frontend/vite.config.ts`, replace the `pkg.version` import with a `fs.readFileSync('../Docker/VERSION', 'utf-8').trim().replace(/^v/, '')` call so both the dev server and production build always reflect the file.
- Update `declare const __APP_VERSION__: string;` in `frontend/src/vite-env.d.ts` if the type declaration needs adjustment (it should not).
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. **Acceptance criteria:**
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]`. - `backend/app/__version__` equals the content of `Docker/VERSION` (without `v` prefix) at runtime.
3. Replace the inline `async with db.execute(...)` block in `main.py` with a single `await geo_service.count_unresolved_cached(db)` call. - `frontend` build constant `__APP_VERSION__` equals the same value.
4. The `main.py` lifespan function must contain no `db.execute` calls of its own. - Bumping only `Docker/VERSION` (e.g. `v0.9.9`) causes both layers to pick up the new version without touching any other file.
- All existing tests pass (`pytest backend/`).
**Status:** ✅ Completed (2026-03-19)
--- ---
#### TASK B-10 — Replace `Any` type usage in `history_service.py` ### Task GV-2 — Expose the BanGUI version through the API
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations. **Scope:** `backend/app/models/server.py`, `backend/app/models/config.py`, `backend/app/routers/dashboard.py`, `backend/app/routers/config.py`
**Files affected:** Add a `bangui_version` field to every API response that already carries the fail2ban daemon `version`, so the frontend can display the BanGUI application version next to it.
- `backend/app/services/history_service.py` — uses `Any` for `geo_enricher` and query parameter lists.
**What to do:** 1. **`backend/app/models/server.py`** — Add to `ServerStatusResponse`:
```python
bangui_version: str = Field(..., description="BanGUI application version.")
```
2. **`backend/app/models/config.py`** — Add to `ServiceStatusResponse`:
```python
bangui_version: str = Field(..., description="BanGUI application version.")
```
3. **`backend/app/routers/dashboard.py`** — In `get_server_status`, import `__version__` from `app` and populate the new field:
```python
return ServerStatusResponse(status=cached, bangui_version=__version__)
```
4. **`backend/app/routers/config.py`** — Do the same for the `GET /api/config/service-status` endpoint.
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. **Do not** change the existing `version` field (fail2ban daemon version) — keep it exactly as-is so nothing downstream breaks.
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). **Acceptance criteria:**
4. Run `mypy --strict` or `pyright` to confirm there are no remaining `Any` usages in `history_service.py`. - `GET /api/dashboard/status` response JSON contains `"bangui_version": "0.9.8"`.
- `GET /api/config/service-status` response JSON contains `"bangui_version": "0.9.8"`.
- All existing backend tests pass.
- Add one test per endpoint asserting that `bangui_version` matches `app.__version__`.
**Status:** ✅ Completed (2026-03-19)
--- ---
#### TASK B-11 — Reduce `Any` usage in `server_service.py` ### Task GV-3 — Display the BanGUI version on Dashboard and Configuration → Server
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations. **Scope:** `frontend/src/components/ServerStatusBar.tsx`, `frontend/src/components/config/ServerHealthSection.tsx`, `frontend/src/types/server.ts`, `frontend/src/types/config.ts`
**Files affected:** After GV-2 the API delivers `bangui_version`; this task makes the frontend show it.
- `backend/app/services/server_service.py` — uses `Any` for raw socket response values and command parameters.
**What to do:** 1. **Type definitions**
- `frontend/src/types/server.ts` — Add `bangui_version: string` to the `ServerStatusResponse` interface.
- `frontend/src/types/config.ts` — Add `bangui_version: string` to the `ServiceStatusResponse` interface.
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. **Dashboard — `ServerStatusBar.tsx`**
2. Replace `Any` with those aliases in `_ok`, `_safe_get`, and other helper functions. The status bar already renders `v{status.version}` (fail2ban version with a tooltip). Add a second badge directly adjacent to it that reads `BanGUI v{status.bangui_version}` with the tooltip `"BanGUI version"`. Match the existing badge style.
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`. 3. **Configuration → Server — `ServerHealthSection.tsx`**
The health section already renders a `Version` row with the fail2ban version. Add a new row below it labelled `BanGUI` (or `BanGUI Version`) that renders `{status.bangui_version}`. Apply the same `statLabel` / `statValue` CSS classes used by the adjacent rows.
4. **Remove the duplicate from the sidebar** — Once the version is visible on the relevant pages, the sidebar footer in `frontend/src/layouts/MainLayout.tsx` can drop `v{__APP_VERSION__}` to avoid showing the version in three places. Replace it with the plain product name `BanGUI` — **only do this if the design document (`Docs/Web-Design.md`) does not mandate showing the version there**; otherwise leave it and note the decision in a comment.
**Acceptance criteria:**
- Dashboard status bar shows `BanGUI v0.9.8` with an appropriate tooltip.
- Configuration → Server health section shows a `BanGUI` version row reading `0.9.8`.
- No TypeScript compile errors (`tsc --noEmit`).
- Both values originate from the same API field (`bangui_version`) and therefore always match the backend version.
**Status:** ✅ Completed (2026-03-19)
--- ---
### 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,68 @@
"""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_docker_version() -> str:
"""Read the project version from ``Docker/VERSION``.
This file is the single source of truth for release scripts and must not be
out of sync with the frontend and backend versions.
"""
repo_root = Path(__file__).resolve().parents[2]
version_path = repo_root / "Docker" / "VERSION"
if not version_path.exists():
raise FileNotFoundError(f"Docker/VERSION not found at {version_path}")
version = version_path.read_text(encoding="utf-8").strip()
return version.lstrip("v")
def _read_version() -> str:
"""Return the current package version.
Prefer the release artifact in ``Docker/VERSION`` when available so the
backend version always matches what the release tooling publishes.
If that file is missing (e.g. in a production wheel or a local checkout),
fall back to ``pyproject.toml`` and finally installed package metadata.
"""
try:
return _read_docker_version()
except FileNotFoundError:
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 fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from app import __version__
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db import init_db from app.db import init_db
from app.routers import ( from app.routers import (
@@ -365,7 +366,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app: FastAPI = FastAPI( app: FastAPI = FastAPI(
title="BanGUI", title="BanGUI",
description="Web interface for monitoring, managing, and configuring fail2ban.", description="Web interface for monitoring, managing, and configuring fail2ban.",
version="0.1.0", version=__version__,
lifespan=_lifespan, lifespan=_lifespan,
) )

View File

@@ -1002,6 +1002,7 @@ class ServiceStatusResponse(BaseModel):
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.") online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.") version: str | None = Field(default=None, description="fail2ban version string, or None when offline.")
bangui_version: str = Field(..., description="BanGUI application version.")
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.") jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.") total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.") total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")

View File

@@ -24,6 +24,7 @@ class ServerStatusResponse(BaseModel):
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
status: ServerStatus status: ServerStatus
bangui_version: str = Field(..., description="BanGUI application version.")
class ServerSettings(BaseModel): class ServerSettings(BaseModel):

View File

@@ -19,6 +19,7 @@ if TYPE_CHECKING:
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, Query, Request
from app import __version__
from app.dependencies import AuthDep from app.dependencies import AuthDep
from app.models.ban import ( from app.models.ban import (
BanOrigin, BanOrigin,
@@ -69,7 +70,7 @@ async def get_server_status(
"server_status", "server_status",
ServerStatus(online=False), ServerStatus(online=False),
) )
return ServerStatusResponse(status=cached) return ServerStatusResponse(status=cached, bangui_version=__version__)
@router.get( @router.get(

View File

@@ -23,7 +23,7 @@ if TYPE_CHECKING:
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from app.dependencies import AuthDep 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.models.history import HistoryListResponse, IpDetailResponse
from app.services import geo_service, history_service from app.services import geo_service, history_service
@@ -52,6 +52,10 @@ async def get_history(
default=None, default=None,
description="Restrict results to IPs matching this prefix.", 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: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query( page_size: int = Query(
default=_DEFAULT_PAGE_SIZE, default=_DEFAULT_PAGE_SIZE,
@@ -89,6 +93,7 @@ async def get_history(
range_=range, range_=range,
jail=jail, jail=jail,
ip_filter=ip, ip_filter=ip,
origin=origin,
page=page, page=page,
page_size=page_size, page_size=page_size,
geo_enricher=_enricher, geo_enricher=_enricher,

View File

@@ -368,8 +368,9 @@ async def update_jail_config(
await _set("datepattern", update.date_pattern) await _set("datepattern", update.date_pattern)
if update.dns_mode is not None: if update.dns_mode is not None:
await _set("usedns", update.dns_mode) await _set("usedns", update.dns_mode)
if update.backend is not None: # Fail2ban does not support changing the log monitoring backend at runtime.
await _set("backend", update.backend) # 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: if update.log_encoding is not None:
await _set("logencoding", update.log_encoding) await _set("logencoding", update.log_encoding)
if update.prefregex is not None: if update.prefregex is not None:
@@ -896,6 +897,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
Returns: Returns:
:class:`~app.models.config.ServiceStatusResponse`. :class:`~app.models.config.ServiceStatusResponse`.
""" """
from app import __version__ # noqa: TCH001 - expose the app release version
from app.services.health_service import probe # lazy import avoids circular dep from app.services.health_service import probe # lazy import avoids circular dep
server_status = await probe(socket_path) server_status = await probe(socket_path)
@@ -921,6 +923,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
return ServiceStatusResponse( return ServiceStatusResponse(
online=server_status.online, online=server_status.online,
version=server_status.version, version=server_status.version,
bangui_version=__version__,
jail_count=server_status.active_jails, jail_count=server_status.active_jails,
total_bans=server_status.total_bans, total_bans=server_status.total_bans,
total_failures=server_status.total_failures, total_failures=server_status.total_failures,

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import aiosqlite
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
import app
from app.config import Settings from app.config import Settings
from app.db import init_db from app.db import init_db
from app.main import create_app from app.main import create_app
@@ -2000,6 +2002,7 @@ class TestGetServiceStatus:
return ServiceStatusResponse( return ServiceStatusResponse(
online=online, online=online,
version="1.0.0" if online else None, version="1.0.0" if online else None,
bangui_version=app.__version__,
jail_count=2 if online else 0, jail_count=2 if online else 0,
total_bans=10 if online else 0, total_bans=10 if online else 0,
total_failures=3 if online else 0, total_failures=3 if online else 0,
@@ -2018,6 +2021,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["online"] is True assert data["online"] is True
assert data["bangui_version"] == app.__version__
assert data["jail_count"] == 2 assert data["jail_count"] == 2
assert data["log_level"] == "INFO" assert data["log_level"] == "INFO"
@@ -2031,6 +2035,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["bangui_version"] == app.__version__
assert data["online"] is False assert data["online"] is False
assert data["log_level"] == "UNKNOWN" assert data["log_level"] == "UNKNOWN"

View File

@@ -9,6 +9,8 @@ import aiosqlite
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
import app
from app.config import Settings from app.config import Settings
from app.db import init_db from app.db import init_db
from app.main import create_app from app.main import create_app
@@ -151,6 +153,9 @@ class TestDashboardStatus:
body = response.json() body = response.json()
assert "status" in body assert "status" in body
assert "bangui_version" in body
assert body["bangui_version"] == app.__version__
status = body["status"] status = body["status"]
assert "online" in status assert "online" in status
assert "version" in status assert "version" in status
@@ -163,8 +168,10 @@ class TestDashboardStatus:
) -> None: ) -> None:
"""Endpoint returns the exact values from ``app.state.server_status``.""" """Endpoint returns the exact values from ``app.state.server_status``."""
response = await dashboard_client.get("/api/dashboard/status") response = await dashboard_client.get("/api/dashboard/status")
status = response.json()["status"] body = response.json()
status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is True assert status["online"] is True
assert status["version"] == "1.0.2" assert status["version"] == "1.0.2"
assert status["active_jails"] == 2 assert status["active_jails"] == 2
@@ -177,8 +184,10 @@ class TestDashboardStatus:
"""Endpoint returns online=False when the cache holds an offline snapshot.""" """Endpoint returns online=False when the cache holds an offline snapshot."""
response = await offline_dashboard_client.get("/api/dashboard/status") response = await offline_dashboard_client.get("/api/dashboard/status")
assert response.status_code == 200 assert response.status_code == 200
status = response.json()["status"] body = response.json()
status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is False assert status["online"] is False
assert status["version"] is None assert status["version"] is None
assert status["active_jails"] == 0 assert status["active_jails"] == 0

View File

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

View File

@@ -256,6 +256,27 @@ class TestUpdateJailConfig:
assert "bantime" in keys assert "bantime" in keys
assert "maxretry" 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: async def test_raises_validation_error_on_bad_regex(self) -> None:
"""update_jail_config raises ConfigValidationError for invalid regex.""" """update_jail_config raises ConfigValidationError for invalid regex."""
from app.models.config import JailConfigUpdate from app.models.config import JailConfigUpdate

View File

@@ -65,6 +65,10 @@ class TestEnsureJailConfigs:
content = _read(jail_d, conf_file) content = _read(jail_d, conf_file)
assert "enabled = false" in content 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 # .local files must set enabled = true and nothing else
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL): for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
content = _read(jail_d, local_file) content = _read(jail_d, local_file)

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from pathlib import Path
import app
def test_app_version_matches_docker_version() -> None:
"""The backend version should match the signed off Docker release version."""
repo_root = Path(__file__).resolve().parents[2]
version_file = repo_root / "Docker" / "VERSION"
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
assert app.__version__ == expected

View File

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

View File

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

View File

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

View File

@@ -83,7 +83,7 @@ const useStyles = makeStyles({
*/ */
export function ServerStatusBar(): React.JSX.Element { export function ServerStatusBar(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { status, loading, error, refresh } = useServerStatus(); const { status, banguiVersion, loading, error, refresh } = useServerStatus();
return ( return (
<div className={styles.bar} role="status" aria-label="fail2ban server status"> <div className={styles.bar} role="status" aria-label="fail2ban server status">
@@ -116,6 +116,14 @@ export function ServerStatusBar(): React.JSX.Element {
</Tooltip> </Tooltip>
)} )}
{banguiVersion != null && (
<Tooltip content="BanGUI version" relationship="description">
<Badge appearance="filled" size="small">
BanGUI v{banguiVersion}
</Badge>
</Tooltip>
)}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* Stats (only when online) */} {/* Stats (only when online) */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -7,6 +7,7 @@
* country filters the companion table. * country filters the companion table.
*/ */
import { createPortal } from "react-dom";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import { Button, makeStyles, tokens } from "@fluentui/react-components"; import { Button, makeStyles, tokens } from "@fluentui/react-components";
@@ -50,6 +51,28 @@ const useStyles = makeStyles({
gap: tokens.spacingVerticalXS, gap: tokens.spacingVerticalXS,
zIndex: 10, zIndex: 10,
}, },
tooltip: {
position: "fixed",
zIndex: 9999,
pointerEvents: "none",
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
borderRadius: tokens.borderRadiusSmall,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXXS,
boxShadow: tokens.shadow4,
},
tooltipCountry: {
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1,
},
tooltipCount: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground2,
},
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -58,6 +81,7 @@ const useStyles = makeStyles({
interface GeoLayerProps { interface GeoLayerProps {
countries: Record<string, number>; countries: Record<string, number>;
countryNames?: Record<string, string>;
selectedCountry: string | null; selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void; onSelectCountry: (cc: string | null) => void;
thresholdLow: number; thresholdLow: number;
@@ -67,6 +91,7 @@ interface GeoLayerProps {
function GeoLayer({ function GeoLayer({
countries, countries,
countryNames,
selectedCountry, selectedCountry,
onSelectCountry, onSelectCountry,
thresholdLow, thresholdLow,
@@ -76,6 +101,17 @@ function GeoLayer({
const styles = useStyles(); const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL }); 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( const handleClick = useCallback(
(cc: string | null): void => { (cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc); onSelectCountry(selectedCountry === cc ? null : cc);
@@ -136,6 +172,30 @@ function GeoLayer({
handleClick(cc); 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
geography={geo} geography={geo}
@@ -179,6 +239,22 @@ function GeoLayer({
); );
}, },
)} )}
{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,
)}
</> </>
); );
} }
@@ -190,6 +266,8 @@ function GeoLayer({
export interface WorldMapProps { export interface WorldMapProps {
/** ISO alpha-2 country code → ban count. */ /** ISO alpha-2 country code → ban count. */
countries: Record<string, number>; countries: Record<string, number>;
/** Optional mapping from country code to display name. */
countryNames?: Record<string, string>;
/** Currently selected country filter (null means no filter). */ /** Currently selected country filter (null means no filter). */
selectedCountry: string | null; selectedCountry: string | null;
/** Called when the user clicks a country or deselects. */ /** Called when the user clicks a country or deselects. */
@@ -204,6 +282,7 @@ export interface WorldMapProps {
export function WorldMap({ export function WorldMap({
countries, countries,
countryNames,
selectedCountry, selectedCountry,
onSelectCountry, onSelectCountry,
thresholdLow = 20, thresholdLow = 20,
@@ -286,6 +365,7 @@ export function WorldMap({
> >
<GeoLayer <GeoLayer
countries={countries} countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry} selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry} onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow} thresholdLow={thresholdLow}

View File

@@ -41,6 +41,7 @@ describe("ServerStatusBar", () => {
it("shows a spinner while the initial load is in progress", () => { it("shows a spinner while the initial load is in progress", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: null, status: null,
banguiVersion: null,
loading: true, loading: true,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -59,6 +60,7 @@ describe("ServerStatusBar", () => {
total_bans: 10, total_bans: 10,
total_failures: 5, total_failures: 5,
}, },
banguiVersion: "1.1.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -76,6 +78,7 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.1.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -93,6 +96,7 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.2.3",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -101,6 +105,24 @@ describe("ServerStatusBar", () => {
expect(screen.getByText("v1.2.3")).toBeInTheDocument(); expect(screen.getByText("v1.2.3")).toBeInTheDocument();
}); });
it("renders a BanGUI version badge", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.2.3",
active_jails: 1,
total_bans: 0,
total_failures: 0,
},
banguiVersion: "9.9.9",
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("BanGUI v9.9.9")).toBeInTheDocument();
});
it("does not render the version element when version is null", () => { it("does not render the version element when version is null", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: { status: {
@@ -110,6 +132,7 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.2.3",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -128,6 +151,7 @@ describe("ServerStatusBar", () => {
total_bans: 21, total_bans: 21,
total_failures: 99, total_failures: 99,
}, },
banguiVersion: "1.0.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -143,6 +167,7 @@ describe("ServerStatusBar", () => {
it("renders an error message when the status fetch fails", () => { it("renders an error message when the status fetch fails", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: null, status: null,
banguiVersion: null,
loading: false, loading: false,
error: "Network error", error: "Network error",
refresh: vi.fn(), refresh: vi.fn(),

View File

@@ -0,0 +1,51 @@
/**
* Tests for WorldMap component.
*
* Verifies that hovering a country shows a tooltip with the country name and ban count.
*/
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] },
}),
}));
import { WorldMap } from "../WorldMap";
describe("WorldMap", () => {
it("shows a tooltip with country name and ban count on hover", () => {
render(
<FluentProvider theme={webLightTheme}>
<WorldMap
countries={{ US: 42 }}
countryNames={{ US: "United States" }}
selectedCountry={null}
onSelectCountry={vi.fn()}
/>
</FluentProvider>,
);
// Tooltip should not be present initially
expect(screen.queryByRole("tooltip")).toBeNull();
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i });
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();
});
});

View File

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

View File

@@ -352,6 +352,12 @@ export function ServerHealthSection(): React.JSX.Element {
<Text className={styles.statValue}>{status.version}</Text> <Text className={styles.statValue}>{status.version}</Text>
</div> </div>
)} )}
{status.bangui_version && (
<div className={styles.statCard}>
<Text className={styles.statLabel}>BanGUI</Text>
<Text className={styles.statValue}>{status.bangui_version}</Text>
</div>
)}
<div className={styles.statCard}> <div className={styles.statCard}>
<Text className={styles.statLabel}>Active Jails</Text> <Text className={styles.statLabel}>Active Jails</Text>
<Text className={styles.statValue}>{status.jail_count}</Text> <Text className={styles.statValue}>{status.jail_count}</Text>

View File

@@ -0,0 +1,84 @@
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>(),
activeFilters: new Set<string>(),
activeActions: new Set<string>(),
loading: false,
error: null,
refresh: vi.fn(),
});
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

@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ServerHealthSection } from "../ServerHealthSection";
vi.mock("../../../api/config");
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
const mockedFetchServiceStatus = vi.mocked(fetchServiceStatus);
const mockedFetchFail2BanLog = vi.mocked(fetchFail2BanLog);
describe("ServerHealthSection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the BanGUI version in the service health panel", async () => {
mockedFetchServiceStatus.mockResolvedValue({
online: true,
version: "1.2.3",
bangui_version: "1.2.3",
jail_count: 2,
total_bans: 5,
total_failures: 1,
log_level: "INFO",
log_target: "STDOUT",
});
mockedFetchFail2BanLog.mockResolvedValue({
log_path: "/var/log/fail2ban.log",
lines: ["2026-01-01 fail2ban[123]: INFO Test"],
total_lines: 1,
log_level: "INFO",
log_target: "STDOUT",
});
render(
<FluentProvider theme={webLightTheme}>
<ServerHealthSection />
</FluentProvider>,
);
// The service health panel should render and include the BanGUI version.
const banGuiLabel = await screen.findByText("BanGUI");
expect(banGuiLabel).toBeInTheDocument();
const banGuiCard = banGuiLabel.closest("div");
expect(banGuiCard).toHaveTextContent("1.2.3");
});
});

View File

@@ -17,6 +17,8 @@ const POLL_INTERVAL_MS = 30_000;
export interface UseServerStatusResult { export interface UseServerStatusResult {
/** The most recent server status snapshot, or `null` before the first fetch. */ /** The most recent server status snapshot, or `null` before the first fetch. */
status: ServerStatus | null; status: ServerStatus | null;
/** BanGUI application version string. */
banguiVersion: string | null;
/** Whether a fetch is currently in flight. */ /** Whether a fetch is currently in flight. */
loading: boolean; loading: boolean;
/** Error message string when the last fetch failed, otherwise `null`. */ /** Error message string when the last fetch failed, otherwise `null`. */
@@ -32,6 +34,7 @@ export interface UseServerStatusResult {
*/ */
export function useServerStatus(): UseServerStatusResult { export function useServerStatus(): UseServerStatusResult {
const [status, setStatus] = useState<ServerStatus | null>(null); const [status, setStatus] = useState<ServerStatus | null>(null);
const [banguiVersion, setBanguiVersion] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -43,6 +46,7 @@ export function useServerStatus(): UseServerStatusResult {
try { try {
const data = await fetchServerStatus(); const data = await fetchServerStatus();
setStatus(data.status); setStatus(data.status);
setBanguiVersion(data.bangui_version);
setError(null); setError(null);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch server status"); setError(err instanceof Error ? err.message : "Failed to fetch server status");
@@ -77,5 +81,5 @@ export function useServerStatus(): UseServerStatusResult {
void doFetch().catch((): void => undefined); void doFetch().catch((): void => undefined);
}, [doFetch]); }, [doFetch]);
return { status, loading, error, refresh }; return { status, banguiVersion, loading, error, refresh };
} }

View File

@@ -313,7 +313,7 @@ export function MainLayout(): React.JSX.Element {
<div className={styles.sidebarFooter}> <div className={styles.sidebarFooter}>
{!collapsed && ( {!collapsed && (
<Text className={styles.versionText}> <Text className={styles.versionText}>
BanGUI v{__APP_VERSION__} BanGUI
</Text> </Text>
)} )}
<Tooltip <Tooltip

View File

@@ -63,16 +63,16 @@ describe("MainLayout", () => {
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
}); });
it("shows the BanGUI version in the sidebar footer when expanded", () => { it("does not show the BanGUI application version in the sidebar footer", () => {
renderLayout(); renderLayout();
// __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define. // __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define.
expect(screen.getByText("BanGUI v0.0.0-test")).toBeInTheDocument(); expect(screen.queryByText(/BanGUI v/)).not.toBeInTheDocument();
}); });
it("hides the BanGUI version text when the sidebar is collapsed", async () => { it("hides the logo text when the sidebar is collapsed", async () => {
renderLayout(); renderLayout();
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i }); const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
await userEvent.click(toggleButton); await userEvent.click(toggleButton);
expect(screen.queryByText("BanGUI v0.0.0-test")).not.toBeInTheDocument(); expect(screen.queryByText("BanGUI")).not.toBeInTheDocument();
}); });
}); });

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
* geo-location details. * geo-location details.
*/ */
import { useState } from "react"; import { useMemo, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -42,7 +42,7 @@ import {
SearchRegular, SearchRegular,
StopRegular, StopRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { Link } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails"; import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { JailSummary } from "../types/jail"; import type { JailSummary } from "../types/jail";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
@@ -152,19 +152,38 @@ function fmtSeconds(s: number): string {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Jail overview columns // Sub-component: Jail overview section
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const jailColumns: TableColumnDefinition<JailSummary>[] = [ 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>({ createTableColumn<JailSummary>({
columnId: "name", columnId: "name",
renderHeaderCell: () => "Jail", renderHeaderCell: () => "Jail",
renderCell: (j) => ( renderCell: (j) => (
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}> <Button
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}> 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} {j.name}
</Text> </Text>
</Link> </Button>
), ),
}), }),
createTableColumn<JailSummary>({ createTableColumn<JailSummary>({
@@ -210,17 +229,9 @@ const jailColumns: TableColumnDefinition<JailSummary>[] = [
renderHeaderCell: () => "Max Retry", renderHeaderCell: () => "Max Retry",
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>, renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
}), }),
]; ],
[navigate],
// --------------------------------------------------------------------------- );
// Sub-component: Jail overview section
// ---------------------------------------------------------------------------
function JailOverviewSection(): React.JSX.Element {
const styles = useStyles();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails();
const [opError, setOpError] = useState<string | null>(null);
const handle = (fn: () => Promise<void>): void => { const handle = (fn: () => Promise<void>): void => {
setOpError(null); setOpError(null);

View File

@@ -12,7 +12,6 @@ import {
Button, Button,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
Select,
Spinner, Spinner,
Table, Table,
TableBody, TableBody,
@@ -22,19 +21,17 @@ import {
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
Text, Text,
Toolbar,
ToolbarButton,
Tooltip, Tooltip,
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons"; import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { WorldMap } from "../components/WorldMap"; import { WorldMap } from "../components/WorldMap";
import { useMapData } from "../hooks/useMapData"; import { useMapData } from "../hooks/useMapData";
import { fetchMapColorThresholds } from "../api/config"; import { fetchMapColorThresholds } from "../api/config";
import type { TimeRange } from "../types/map"; import type { TimeRange } from "../types/map";
import type { BanOriginFilter } from "../types/ban"; import type { BanOriginFilter } from "../types/ban";
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles // Styles
@@ -56,34 +53,23 @@ const useStyles = makeStyles({
flexWrap: "wrap", flexWrap: "wrap",
gap: tokens.spacingHorizontalM, 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: { tableWrapper: {
overflow: "auto", overflow: "auto",
maxHeight: "420px", maxHeight: "420px",
borderRadius: tokens.borderRadiusMedium, borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`, 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 // MapPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -136,41 +122,20 @@ export function MapPage(): React.JSX.Element {
World Map World Map
</Text> </Text>
<Toolbar size="small"> <div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
<Select <DashboardFilterBar
aria-label="Time range" timeRange={range}
value={range} onTimeRangeChange={(value) => {
onChange={(_ev, data): void => { setRange(value);
setRange(data.value as TimeRange);
setSelectedCountry(null); setSelectedCountry(null);
}} }}
size="small" originFilter={originFilter}
> onOriginFilterChange={(value) => {
{TIME_RANGE_OPTIONS.map((o) => ( setOriginFilter(value);
<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);
setSelectedCountry(null); setSelectedCountry(null);
}} }}
size="small" />
> <Button
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
<option key={f} value={f}>
{BAN_ORIGIN_FILTER_LABELS[f]}
</option>
))}
</Select>
<ToolbarButton
icon={<ArrowCounterclockwiseRegular />} icon={<ArrowCounterclockwiseRegular />}
onClick={(): void => { onClick={(): void => {
refresh(); refresh();
@@ -178,7 +143,7 @@ export function MapPage(): React.JSX.Element {
disabled={loading} disabled={loading}
title="Refresh" title="Refresh"
/> />
</Toolbar> </div>
</div> </div>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
@@ -202,6 +167,7 @@ export function MapPage(): React.JSX.Element {
{!loading && !error && ( {!loading && !error && (
<WorldMap <WorldMap
countries={countries} countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry} selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry} onSelectCountry={setSelectedCountry}
thresholdLow={thresholdLow} thresholdLow={thresholdLow}

View File

@@ -6,7 +6,11 @@ import { ConfigPage } from "../ConfigPage";
// Mock all tab components to avoid deep render trees and API calls. // Mock all tab components to avoid deep render trees and API calls.
vi.mock("../../components/config", () => ({ 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>, FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>, ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
ServerTab: () => <div data-testid="server-tab">ServerTab</div>, ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
@@ -53,4 +57,22 @@ describe("ConfigPage", () => {
renderPage(); renderPage();
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument(); 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,67 @@
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,
}));
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
vi.mock("../components/WorldMap", () => ({
WorldMap: (props: unknown) => {
mockWorldMap(props);
return <div data-testid="world-map" />;
},
}));
describe("MapPage", () => {
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" });
// Map should receive country names from the hook so tooltips can show human-readable labels.
expect(mockWorldMap).toHaveBeenCalled();
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
expect(firstCallArgs).toMatchObject({ countryNames: {} });
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
expect(lastArgs.range).toBe("7d");
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
expect(lastArgs.origin).toBe("blocklist");
});
});

View File

@@ -661,6 +661,8 @@ export interface ServiceStatusResponse {
online: boolean; online: boolean;
/** fail2ban version string, or null when offline. */ /** fail2ban version string, or null when offline. */
version: string | null; version: string | null;
/** BanGUI application version (from the API). */
bangui_version: string;
/** Number of currently active jails. */ /** Number of currently active jails. */
jail_count: number; jail_count: number;
/** Aggregated current ban count across all jails. */ /** Aggregated current ban count across all jails. */

View File

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

View File

@@ -21,4 +21,6 @@ export interface ServerStatus {
/** Response shape for ``GET /api/dashboard/status``. */ /** Response shape for ``GET /api/dashboard/status``. */
export interface ServerStatusResponse { export interface ServerStatusResponse {
status: ServerStatus; status: ServerStatus;
/** BanGUI application version (from the API). */
bangui_version: string;
} }

View File

@@ -3,16 +3,19 @@ import react from "@vitejs/plugin-react";
import { resolve } from "path"; import { resolve } from "path";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
const pkg = JSON.parse( const appVersion = readFileSync(
readFileSync(resolve(__dirname, "package.json"), "utf-8"), resolve(__dirname, "../Docker/VERSION"),
) as { version: string }; "utf-8",
)
.trim()
.replace(/^v/, "");
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: { define: {
/** BanGUI application version injected at build time from package.json. */ /** BanGUI application version injected at build time from Docker/VERSION. */
__APP_VERSION__: JSON.stringify(pkg.version), __APP_VERSION__: JSON.stringify(appVersion),
}, },
resolve: { resolve: {
alias: { alias: {

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