21 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
8f515893ea refactoring tasks 2026-03-16 20:51:07 +01:00
81f99d0b50 release script 2026-03-16 20:04:36 +01:00
030bca09b7 version 2026-03-16 20:02:22 +01:00
49 changed files with 1339 additions and 228 deletions

View File

@@ -1 +1 @@
v0.9.3
v0.9.10

View File

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

View File

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

View File

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

238
Docs/Refactoring.md Normal file
View File

@@ -0,0 +1,238 @@
# BanGUI — Refactoring Instructions for AI Agents
This document is the single source of truth for any AI agent performing a refactoring task on the BanGUI codebase.
Read it in full before writing a single line of code.
The authoritative description of every module, its responsibilities, and the allowed dependency direction is in [Architekture.md](Architekture.md). Always cross-reference it.
---
## 0. Golden Rules
1. **Architecture first.** Every change must comply with the layered architecture defined in [Architekture.md §2](Architekture.md). Dependencies flow inward: `routers → services → repositories`. Never add an import that reverses this direction.
2. **One concern per file.** Each module has an explicitly stated purpose in [Architekture.md](Architekture.md). Do not add responsibilities to a module that do not belong there.
3. **No behaviour change.** Refactoring must preserve all existing behaviour. If a function's public signature, return value, or side-effects must change, that is a feature — create a separate task for it.
4. **Tests stay green.** Run the full test suite (`pytest backend/`) before and after every change. Do not submit work that introduces new failures.
5. **Smallest diff wins.** Prefer targeted edits. Do not rewrite a file when a few lines suffice.
---
## 1. Before You Start
### 1.1 Understand the project
Read the following documents in order:
1. [Architekture.md](Architekture.md) — full system overview, component map, module purposes, dependency rules.
2. [Docs/Backend-Development.md](Backend-Development.md) — coding conventions, testing strategy, environment setup.
3. [Docs/Tasks.md](Tasks.md) — open issues and planned work; avoid touching areas that have pending conflicting changes.
### 1.2 Map the code to the architecture
Before editing, locate every file that is in scope:
```
backend/app/
routers/ HTTP layer — zero business logic
services/ Business logic — orchestrates repositories + clients
repositories/ Data access — raw SQL only
models/ Pydantic schemas
tasks/ APScheduler jobs
utils/ Pure helpers, no framework deps
main.py App factory, lifespan, middleware
config.py Pydantic settings
dependencies.py FastAPI Depends() wiring
frontend/src/
api/ Typed fetch wrappers + endpoint constants
components/ Presentational UI, no API calls
hooks/ All state, side-effects, API calls
pages/ Route components — orchestration only
providers/ React context
types/ TypeScript interfaces
utils/ Pure helpers
```
Confirm which layer every file you intend to touch belongs to. If unsure, consult [Architekture.md §2.2](Architekture.md) (backend) or [Architekture.md §3.2](Architekture.md) (frontend).
### 1.3 Run the baseline
```bash
# Backend
pytest backend/ -x --tb=short
# Frontend
cd frontend && npm run test
```
Record the number of passing tests. After refactoring, that number must be equal or higher.
---
## 2. Backend Refactoring
### 2.1 Routers (`app/routers/`)
**Allowed content:** request parsing, response serialisation, dependency injection via `Depends()`, delegation to a service, HTTP error mapping.
**Forbidden content:** SQL queries, business logic, direct use of `fail2ban_client`, any logic that would also make sense in a unit test without an HTTP request.
Checklist:
- [ ] Every handler calls exactly one service method per logical operation.
- [ ] No `if`/`elif` chains that implement business rules — move these to the service.
- [ ] No raw SQL or repository imports.
- [ ] All response models are Pydantic schemas from `app/models/`.
- [ ] HTTP status codes are consistent with API conventions (200 OK, 201 Created, 204 No Content, 400/422 for client errors, 404 for missing resources, 500 only for unexpected failures).
### 2.2 Services (`app/services/`)
**Allowed content:** business rules, coordination between repositories and external clients, validation that goes beyond Pydantic, fail2ban command orchestration.
**Forbidden content:** raw SQL, direct aiosqlite calls, FastAPI `HTTPException` (raise domain exceptions instead and let the router or exception handler convert them).
Checklist:
- [ ] Service classes / functions accept plain Python types or domain models — not `Request` or `Response` objects.
- [ ] No direct `aiosqlite` usage — go through a repository.
- [ ] No `HTTPException` — raise a custom domain exception or a plain `ValueError`/`RuntimeError` with a clear message.
- [ ] No circular imports between services — if two services need each other's logic, extract the shared logic to a utility or a third service.
### 2.3 Repositories (`app/repositories/`)
**Allowed content:** SQL queries, result mapping to domain models, transaction management.
**Forbidden content:** business logic, fail2ban calls, HTTP concerns, logging beyond debug-level traces.
Checklist:
- [ ] Every public method accepts a `db: aiosqlite.Connection` parameter — sessions are not managed internally.
- [ ] Methods return typed domain models or plain Python primitives, never raw `aiosqlite.Row` objects exposed to callers.
- [ ] No business rules (e.g., no "if this setting is missing, create a default" logic — that belongs in the service).
### 2.4 Models (`app/models/`)
- Keep **Request**, **Response**, and **Domain** model types clearly separated (see [Architekture.md §2.2](Architekture.md)).
- Do not use response models as function arguments inside service or repository code.
- Validators (`@field_validator`, `@model_validator`) belong in models only when they concern data shape, not business rules.
### 2.5 Tasks (`app/tasks/`)
- Tasks must be thin: fetch inputs → call one service method → log result.
- Error handling must be inside the task (APScheduler swallows unhandled exceptions — log them explicitly).
- No direct repository or `fail2ban_client` use; go through a service.
### 2.6 Utils (`app/utils/`)
- Must have zero framework dependencies (no FastAPI, no aiosqlite imports).
- Must be pure or near-pure functions.
- `fail2ban_client.py` is the single exception — it wraps the socket protocol but still has no service-layer logic.
### 2.7 Dependencies (`app/dependencies.py`)
- This file is the **only** place where service constructors are called and injected.
- Do not construct services inside router handlers; always receive them via `Depends()`.
---
## 3. Frontend Refactoring
### 3.1 Pages (`src/pages/`)
**Allowed content:** composing components and hooks, layout decisions, routing.
**Forbidden content:** direct `fetch`/`axios` calls, inline business logic, state management beyond what is needed to coordinate child components.
Checklist:
- [ ] All data fetching goes through a hook from `src/hooks/`.
- [ ] No API function from `src/api/` is called directly inside a page component.
### 3.2 Components (`src/components/`)
**Allowed content:** rendering, styling, event handlers that call prop callbacks.
**Forbidden content:** API calls, hook-level state (prefer lifting state to the page or a dedicated hook), direct use of `src/api/`.
Checklist:
- [ ] Components receive all data via props.
- [ ] Components emit changes via callback props (`onXxx`).
- [ ] No `useEffect` that calls an API function — that belongs in a hook.
### 3.3 Hooks (`src/hooks/`)
**Allowed content:** `useState`, `useEffect`, `useCallback`, `useRef`; calls to `src/api/`; local state derivation.
**Forbidden content:** JSX rendering, Fluent UI components.
Checklist:
- [ ] Each hook has a single, focused concern matching its name (e.g., `useBans` only manages ban data).
- [ ] Hooks return a stable interface: `{ data, loading, error, refetch }` or equivalent.
- [ ] Shared logic between hooks is extracted to `src/utils/` (pure) or a parent hook (stateful).
### 3.4 API layer (`src/api/`)
- `client.ts` is the only place that calls `fetch`. All other api files call `client.ts`.
- `endpoints.ts` is the single source of truth for URL strings.
- API functions must be typed: explicit request and response TypeScript interfaces from `src/types/`.
### 3.5 Types (`src/types/`)
- Interfaces must match the backend Pydantic response schemas exactly (field names, optionality).
- Do not use `any`. Use `unknown` and narrow with type guards when the shape is genuinely unknown.
---
## 4. General Code Quality Rules
### Naming
- Python: `snake_case` for variables/functions, `PascalCase` for classes.
- TypeScript: `camelCase` for variables/functions, `PascalCase` for components and types.
- File names must match the primary export they contain.
### Error handling
- Backend: raise typed exceptions; map them to HTTP status codes in `main.py` exception handlers or in the router — nowhere else.
- Frontend: all API call error states are represented in hook return values; never swallow errors silently.
### Logging (backend)
- Use `structlog` with bound context loggers — never bare `print()`.
- Log at `debug` in repositories, `info` in services for meaningful events, `warning`/`error` in tasks and exception handlers.
- Never log sensitive data (passwords, session tokens, raw IP lists larger than a handful of entries).
### Async correctness (backend)
- Every function that touches I/O (database, fail2ban socket, HTTP) must be `async def`.
- Never call `asyncio.run()` inside a running event loop.
- Do not use `time.sleep()` — use `await asyncio.sleep()`.
---
## 5. Refactoring Workflow
Follow this sequence for every refactoring task:
1. **Read** the relevant section of [Architekture.md](Architekture.md) for the files you will touch.
2. **Run** the full test suite to confirm the baseline.
3. **Identify** the violation or smell: which rule from this document does it break?
4. **Plan** the minimal change: what is the smallest edit that fixes the violation?
5. **Edit** the code. One logical change per commit.
6. **Verify** imports: nothing new violates the dependency direction.
7. **Run** the full test suite. All previously passing tests must still pass.
8. **Update** any affected docstrings or inline comments to reflect the new structure.
9. **Do not** update `Architekture.md` unless the refactor changes the documented structure — that requires a separate review.
---
## 6. Common Violations to Look For
| Violation | Where it typically appears | Fix |
|---|---|---|
| Business logic in a router handler | `app/routers/*.py` | Extract logic to the corresponding service |
| Direct `aiosqlite` calls in a service | `app/services/*.py` | Move the query into the matching repository |
| `HTTPException` raised inside a service | `app/services/*.py` | Raise a domain exception; catch and convert it in the router or exception handler |
| API call inside a React component | `src/components/*.tsx` | Move to a hook; pass data via props |
| Hardcoded URL string in a hook or component | `src/hooks/*.ts`, `src/components/*.tsx` | Use the constant from `src/api/endpoints.ts` |
| `any` type in TypeScript | anywhere in `src/` | Replace with a concrete interface from `src/types/` |
| `print()` statements in production code | `backend/app/**/*.py` | Replace with `structlog` logger |
| Synchronous I/O in an async function | `backend/app/**/*.py` | Use the async equivalent |
| A repository method that contains an `if` with a business rule | `app/repositories/*.py` | Move the rule to the service layer |
---
## 7. Out of Scope
Do not make the following changes unless explicitly instructed in a separate task:
- Adding new API endpoints or pages.
- Changing database schema or migration files.
- Upgrading dependencies.
- Altering Docker or CI configuration.
- Modifying `Architekture.md` or `Tasks.md`.

View File

@@ -6,54 +6,217 @@ This document breaks the entire BanGUI project into development stages, ordered
## Open Issues
### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done
**Implemented:**
- `frontend/vite.config.ts`: reads `package.json#version` at build time and injects it as the global `__APP_VERSION__` via Vite `define`.
- `frontend/src/vite-env.d.ts`: adds `declare const __APP_VERSION__: string` so TypeScript knows about the global.
- `frontend/src/layouts/MainLayout.tsx`: renders `BanGUI v{__APP_VERSION__}` in the sidebar footer when expanded (hidden when collapsed).
- `frontend/src/components/ServerStatusBar.tsx`: tooltip changed from `"fail2ban version"` to `"fail2ban daemon version"`.
- `Docker/release.sh`: after bumping `VERSION`, also updates `frontend/package.json#version` via `sed` to keep them in sync.
- `frontend/package.json`: version bumped from `0.9.0` to `0.9.3` to match `Docker/VERSION`.
- Tests added: `src/components/__tests__/ServerStatusBar.test.tsx`, `src/layouts/__tests__/MainLayout.test.tsx`.
**Problem:** The `ServerStatusBar` component on the Dashboard displays `v{status.version}`, which is the **fail2ban daemon version** (e.g. `v1.1.0`). The BanGUI application version lives in `Docker/VERSION` (e.g. `v0.9.3`) and is unrelated to the fail2ban version. Users see a version number they don't recognise and assume it reflects the BanGUI release.
**Goal:** Make the distinction clear and expose the BanGUI application version.
**Suggested approach:**
1. Inject the BanGUI app version at build time — add a `define` entry in `frontend/vite.config.ts` that reads the `version` field from `frontend/package.json` (e.g. `__APP_VERSION__`). Keep `frontend/package.json` and `Docker/VERSION` in sync (update the release script `Docker/release.sh` or `Makefile` to write `package.json#version` from `VERSION`).
2. Show the BanGUI version in the sidebar footer inside `MainLayout.tsx` (collapsed view: show only when expanded, or via tooltip). This is the natural place for an "about" version tag.
3. Update the fail2ban version tooltip in `ServerStatusBar.tsx` from the generic `"fail2ban version"` to something like `"fail2ban daemon version"` so the two are no longer visually indistinguishable.
**Files:** `frontend/vite.config.ts`, `frontend/package.json`, `Docker/VERSION`, `Docker/release.sh`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/components/ServerStatusBar.tsx`.
> **Architectural Review — 2026-03-16**
> The findings below were identified by auditing every backend and frontend module against the rules in [Refactoring.md](Refactoring.md) and [Architekture.md](Architekture.md).
> Tasks are grouped by layer and ordered so that lower-level fixes (repositories, services) are done before the layers that depend on them.
---
### ~~2. Dashboard — Improve "Failures" Tooltip~~ ✅ Done
## Feature: Worldmap Country Tooltip
**Implemented:** In `frontend/src/components/ServerStatusBar.tsx`, changed the `Failures:` label to `Failed Attempts:` and updated the tooltip from `"Currently failing IPs"` to `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Updated `ServerStatusBar.test.tsx` to assert the new label text.
**Problem:** The `ServerStatusBar` shows a "Failures: 42" counter with the tooltip `"Currently failing IPs"`. In fail2ban terminology *failures* are individual **failed authentication attempts** tracked in the fail2ban DB, not the number of unique IPs that failed. The current wording is ambiguous and misleading — users may think it means broken connections or error states.
**Goal:** Replace the tooltip with accurate, self-explanatory wording.
**Suggested fix:** Change the `Tooltip` content for the Failures stat in `ServerStatusBar.tsx` from `"Currently failing IPs"` to something like `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Additionally, consider renaming the label from `"Failures:"` to `"Failed Attempts:"` to match the tooltip language.
**Files:** `frontend/src/components/ServerStatusBar.tsx`.
> **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.
---
### ~~3. Config → Server Tab — Move "Service Health" to Top~~ ✅ Done
### Task WM-1 — Show country name and ban count tooltip on map hover
**Implemented:** In `frontend/src/components/config/ServerTab.tsx`, moved `<ServerHealthSection />` from the end of the JSX return to be the first element rendered inside the tab container, before all settings fields.
**Scope:** `frontend/src/components/WorldMap.tsx`, `frontend/src/pages/MapPage.tsx`
**Problem:** In the Config page → Server tab, the `Service Health` panel (`ServerHealthSection`) is rendered at the bottom of the tab, after all settings sections (log level, log target, DB purge settings, map thresholds, reload/restart buttons). This means users must scroll past all editable fields to check service connectivity status, even though the health status is the most critical piece of context — it indicates whether the server is reachable at all.
`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.
**Goal:** Move the `<ServerHealthSection />` block to the **top** of the `ServerTab` render output, before any settings fields.
**Implementation steps:**
**Suggested fix:** In `frontend/src/components/config/ServerTab.tsx`, move the `{/* Service Health & Log Viewer section */}` block (currently at the end of the JSX return around line 415) to be the first section rendered inside the tab container.
1. **Extend `WorldMapProps` and `GeoLayerProps`** in `WorldMap.tsx`:
- Add `countryNames?: Record<string, string>` to `WorldMapProps` (optional — falls back to the ISO alpha-2 code when absent).
- Thread it through `GeoLayer` the same way the threshold props are already threaded.
**Files:** `frontend/src/components/config/ServerTab.tsx`.
2. **Add hover state to `GeoLayer`** — declare:
```ts
const [tooltip, setTooltip] = useState<{
cc: string;
count: number;
name: string;
x: number;
y: number;
} | null>(null);
```
On each country `<g>` element add:
- `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`).
- `onMouseMove` — update only the `x`/`y` in the existing tooltip (keep name/count stable).
- `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)
---
## Feature: Global Unique BanGUI Version
> **2026-03-17**
> The BanGUI application version is currently scattered across three independent files that are not kept in sync:
> - `Docker/VERSION` — `v0.9.8` (release artifact, written by the release script)
> - `frontend/package.json` — `0.9.8`
> - `backend/pyproject.toml` — `0.9.4` ← **out of sync**
>
> 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.
>
> Goal: one authoritative version string, propagated automatically to all layers, and displayed consistently on both the Dashboard and the Configuration → Server page.
---
### Task GV-1 — Establish a single source of truth for the BanGUI version
**Scope:** `Docker/VERSION`, `backend/pyproject.toml`, `frontend/package.json`, `backend/app/__init__.py`
`Docker/VERSION` is already the file written by the release script (`Docker/release.sh`) and is therefore the natural single source of truth.
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).
**Acceptance criteria:**
- `backend/app/__version__` equals the content of `Docker/VERSION` (without `v` prefix) at runtime.
- `frontend` build constant `__APP_VERSION__` equals the same value.
- 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 GV-2 — Expose the BanGUI version through the API
**Scope:** `backend/app/models/server.py`, `backend/app/models/config.py`, `backend/app/routers/dashboard.py`, `backend/app/routers/config.py`
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.
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.
**Do not** change the existing `version` field (fail2ban daemon version) — keep it exactly as-is so nothing downstream breaks.
**Acceptance criteria:**
- `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 GV-3 — Display the BanGUI version on Dashboard and Configuration → Server
**Scope:** `frontend/src/components/ServerStatusBar.tsx`, `frontend/src/components/config/ServerHealthSection.tsx`, `frontend/src/types/server.ts`, `frontend/src/types/config.ts`
After GV-2 the API delivers `bangui_version`; this task makes the frontend show it.
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.
2. **Dashboard — `ServerStatusBar.tsx`**
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. **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)
---

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

View File

@@ -1002,6 +1002,7 @@ class ServiceStatusResponse(BaseModel):
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.")
bangui_version: str = Field(..., description="BanGUI application version.")
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_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)
status: ServerStatus
bangui_version: str = Field(..., description="BanGUI application version.")
class ServerSettings(BaseModel):

View File

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

View File

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

View File

@@ -368,8 +368,9 @@ async def update_jail_config(
await _set("datepattern", update.date_pattern)
if update.dns_mode is not None:
await _set("usedns", update.dns_mode)
if update.backend is not None:
await _set("backend", update.backend)
# Fail2ban does not support changing the log monitoring backend at runtime.
# The configuration value is retained for read/display purposes but must not
# be applied via the socket API.
if update.log_encoding is not None:
await _set("logencoding", update.log_encoding)
if update.prefregex is not None:
@@ -896,6 +897,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
Returns:
: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
server_status = await probe(socket_path)
@@ -921,6 +923,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
return ServiceStatusResponse(
online=server_status.online,
version=server_status.version,
bangui_version=__version__,
jail_count=server_status.active_jails,
total_bans=server_status.total_bans,
total_failures=server_status.total_failures,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -213,6 +213,18 @@ class TestHistoryList:
_args, kwargs = mock_fn.call_args
assert kwargs.get("range_") == "7d"
async def test_forwards_origin_filter(self, history_client: AsyncClient) -> None:
"""The ``origin`` query parameter is forwarded to the service."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?origin=blocklist")
_args, kwargs = mock_fn.call_args
assert kwargs.get("origin") == "blocklist"
async def test_empty_result(self, history_client: AsyncClient) -> None:
"""An empty history returns items=[] and total=0."""
with patch(

View File

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

View File

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

View File

@@ -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",
"version": "0.1.0",
"version": "0.9.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bangui-frontend",
"version": "0.1.0",
"version": "0.9.4",
"dependencies": {
"@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257",

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
* country filters the companion table.
*/
import { createPortal } from "react-dom";
import { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import { Button, makeStyles, tokens } from "@fluentui/react-components";
@@ -50,6 +51,28 @@ const useStyles = makeStyles({
gap: tokens.spacingVerticalXS,
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 {
countries: Record<string, number>;
countryNames?: Record<string, string>;
selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void;
thresholdLow: number;
@@ -67,6 +91,7 @@ interface GeoLayerProps {
function GeoLayer({
countries,
countryNames,
selectedCountry,
onSelectCountry,
thresholdLow,
@@ -76,6 +101,17 @@ function GeoLayer({
const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL });
const [tooltip, setTooltip] = useState<
| {
cc: string;
count: number;
name: string;
x: number;
y: number;
}
| null
>(null);
const handleClick = useCallback(
(cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc);
@@ -98,7 +134,7 @@ function GeoLayer({
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
const isSelected = cc !== null && selectedCountry === cc;
// Compute the fill color based on ban count
const fillColor = getBanCountColor(
count,
@@ -106,7 +142,7 @@ function GeoLayer({
thresholdMedium,
thresholdHigh,
);
// Only calculate centroid if path is available
let cx: number | undefined;
let cy: number | undefined;
@@ -136,6 +172,30 @@ function GeoLayer({
handleClick(cc);
}
}}
onMouseEnter={(e): void => {
if (!cc) return;
setTooltip({
cc,
count,
name: countryNames?.[cc] ?? cc,
x: e.clientX,
y: e.clientY,
});
}}
onMouseMove={(e): void => {
setTooltip((current) =>
current
? {
...current,
x: e.clientX,
y: e.clientY,
}
: current,
);
}}
onMouseLeave={(): void => {
setTooltip(null);
}}
>
<Geography
geography={geo}
@@ -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 {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** Optional mapping from country code to display name. */
countryNames?: Record<string, string>;
/** Currently selected country filter (null means no filter). */
selectedCountry: string | null;
/** Called when the user clicks a country or deselects. */
@@ -204,6 +282,7 @@ export interface WorldMapProps {
export function WorldMap({
countries,
countryNames,
selectedCountry,
onSelectCountry,
thresholdLow = 20,
@@ -286,6 +365,7 @@ export function WorldMap({
>
<GeoLayer
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow}

View File

@@ -41,6 +41,7 @@ describe("ServerStatusBar", () => {
it("shows a spinner while the initial load is in progress", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
banguiVersion: null,
loading: true,
error: null,
refresh: vi.fn(),
@@ -59,6 +60,7 @@ describe("ServerStatusBar", () => {
total_bans: 10,
total_failures: 5,
},
banguiVersion: "1.1.0",
loading: false,
error: null,
refresh: vi.fn(),
@@ -76,6 +78,7 @@ describe("ServerStatusBar", () => {
total_bans: 0,
total_failures: 0,
},
banguiVersion: "1.1.0",
loading: false,
error: null,
refresh: vi.fn(),
@@ -93,6 +96,7 @@ describe("ServerStatusBar", () => {
total_bans: 0,
total_failures: 0,
},
banguiVersion: "1.2.3",
loading: false,
error: null,
refresh: vi.fn(),
@@ -101,6 +105,24 @@ describe("ServerStatusBar", () => {
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", () => {
mockedUseServerStatus.mockReturnValue({
status: {
@@ -110,6 +132,7 @@ describe("ServerStatusBar", () => {
total_bans: 0,
total_failures: 0,
},
banguiVersion: "1.2.3",
loading: false,
error: null,
refresh: vi.fn(),
@@ -128,6 +151,7 @@ describe("ServerStatusBar", () => {
total_bans: 21,
total_failures: 99,
},
banguiVersion: "1.0.0",
loading: false,
error: null,
refresh: vi.fn(),
@@ -143,6 +167,7 @@ describe("ServerStatusBar", () => {
it("renders an error message when the status fetch fails", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
banguiVersion: null,
loading: false,
error: "Network error",
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,
date_pattern: datePattern !== "" ? datePattern : null,
dns_mode: dnsMode,
backend,
log_encoding: logEncoding,
prefregex: prefRegex !== "" ? prefRegex : null,
bantime_escalation: {
@@ -231,7 +230,7 @@ function JailConfigDetail({
}),
[
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor,
dnsMode, logEncoding, prefRegex, escEnabled, escFactor,
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
jail.ban_time, jail.find_time, jail.max_retry,
],
@@ -758,7 +757,12 @@ function InactiveJailDetail({
*
* @returns JSX element.
*/
export function JailsTab(): React.JSX.Element {
interface JailsTabProps {
/** Jail name to pre-select when the component mounts. */
initialJail?: string;
}
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail } =
useJailConfigs();
@@ -819,6 +823,13 @@ export function JailsTab(): React.JSX.Element {
return [...activeItems, ...inactiveItems];
}, [jails, inactiveJails]);
useEffect(() => {
if (!initialJail || selectedName) return;
if (listItems.some((item) => item.name === initialJail)) {
setSelectedName(initialJail);
}
}, [initialJail, listItems, selectedName]);
const activeJailMap = useMemo(
() => new Map(jails.map((j) => [j.name, j])),
[jails],

View File

@@ -352,6 +352,12 @@ export function ServerHealthSection(): React.JSX.Element {
<Text className={styles.statValue}>{status.version}</Text>
</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}>
<Text className={styles.statLabel}>Active Jails</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 {
/** The most recent server status snapshot, or `null` before the first fetch. */
status: ServerStatus | null;
/** BanGUI application version string. */
banguiVersion: string | null;
/** Whether a fetch is currently in flight. */
loading: boolean;
/** Error message string when the last fetch failed, otherwise `null`. */
@@ -32,6 +34,7 @@ export interface UseServerStatusResult {
*/
export function useServerStatus(): UseServerStatusResult {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [banguiVersion, setBanguiVersion] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -43,6 +46,7 @@ export function useServerStatus(): UseServerStatusResult {
try {
const data = await fetchServerStatus();
setStatus(data.status);
setBanguiVersion(data.bangui_version);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch server status");
@@ -77,5 +81,5 @@ export function useServerStatus(): UseServerStatusResult {
void doFetch().catch((): void => undefined);
}, [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}>
{!collapsed && (
<Text className={styles.versionText}>
BanGUI v{__APP_VERSION__}
BanGUI
</Text>
)}
<Tooltip

View File

@@ -63,16 +63,16 @@ describe("MainLayout", () => {
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();
// __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();
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
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
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
import {
ActionsTab,
@@ -58,8 +59,16 @@ type TabValue =
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
const location = useLocation();
const [tab, setTab] = useState<TabValue>("jails");
useEffect(() => {
const state = location.state as { tab?: string; jail?: string } | null;
if (state?.tab === "jails") {
setTab("jails");
}
}, [location.state]);
return (
<div className={styles.page}>
<div className={styles.header}>
@@ -86,7 +95,11 @@ export function ConfigPage(): React.JSX.Element {
</TabList>
<div className={styles.tabContent} key={tab}>
{tab === "jails" && <JailsTab />}
{tab === "jails" && (
<JailsTab
initialJail={(location.state as { jail?: string } | null)?.jail}
/>
)}
{tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />}
{tab === "server" && <ServerTab />}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { HistoryPage } from "../HistoryPage";
let lastQuery: Record<string, unknown> | null = null;
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
lastQuery = query;
return {
items: [],
total: 0,
page: 1,
loading: false,
error: null,
setPage: vi.fn(),
refresh: vi.fn(),
};
});
vi.mock("../hooks/useHistory", () => ({
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
}));
vi.mock("../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />,
}));
vi.mock("../api/config", () => ({
fetchMapColorThresholds: async () => ({
threshold_low: 10,
threshold_medium: 50,
threshold_high: 100,
}),
}));
describe("HistoryPage", () => {
it("renders DashboardFilterBar and applies origin+range filters", async () => {
const user = userEvent.setup();
render(
<FluentProvider theme={webLightTheme}>
<HistoryPage />
</FluentProvider>,
);
// Initial load should include the default query.
expect(lastQuery).toEqual({ page_size: 50 });
// Change the time-range and origin filter, then apply.
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
await user.click(screen.getByRole("button", { name: /Apply/i }));
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
});
});

View File

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

View File

@@ -0,0 +1,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;
/** fail2ban version string, or null when offline. */
version: string | null;
/** BanGUI application version (from the API). */
bangui_version: string;
/** Number of currently active jails. */
jail_count: number;
/** Aggregated current ban count across all jails. */

View File

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

View File

@@ -21,4 +21,6 @@ export interface ServerStatus {
/** Response shape for ``GET /api/dashboard/status``. */
export interface ServerStatusResponse {
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 { readFileSync } from "node:fs";
const pkg = JSON.parse(
readFileSync(resolve(__dirname, "package.json"), "utf-8"),
) as { version: string };
const appVersion = readFileSync(
resolve(__dirname, "../Docker/VERSION"),
"utf-8",
)
.trim()
.replace(/^v/, "");
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
define: {
/** BanGUI application version injected at build time from package.json. */
__APP_VERSION__: JSON.stringify(pkg.version),
/** BanGUI application version injected at build time from Docker/VERSION. */
__APP_VERSION__: JSON.stringify(appVersion),
},
resolve: {
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