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 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,7 +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}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 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" "${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."

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 ## Open Issues
### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done > **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).
**Implemented:** > Tasks are grouped by layer and ordered so that lower-level fixes (repositories, services) are done before the layers that depend on them.
- `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`.
--- ---
### ~~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. > **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.
**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: 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.
**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`.
--- ---
### ~~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 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.3", "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);
@@ -98,7 +134,7 @@ function GeoLayer({
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
const count: number = cc !== null ? (countries[cc] ?? 0) : 0; const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
const isSelected = cc !== null && selectedCountry === cc; const isSelected = cc !== null && selectedCountry === cc;
// Compute the fill color based on ban count // Compute the fill color based on ban count
const fillColor = getBanCountColor( const fillColor = getBanCountColor(
count, count,
@@ -106,7 +142,7 @@ function GeoLayer({
thresholdMedium, thresholdMedium,
thresholdHigh, thresholdHigh,
); );
// Only calculate centroid if path is available // Only calculate centroid if path is available
let cx: number | undefined; let cx: number | undefined;
let cy: number | undefined; let cy: number | undefined;
@@ -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 => { originFilter={originFilter}
setRange(data.value === "" ? undefined : (data.value as TimeRange)); onOriginFilterChange={(value) => {
}} setOriginFilter(value);
size="small" }}
> />
<option value="">All time</option>
{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";
@@ -151,77 +151,88 @@ function fmtSeconds(s: number): string {
return `${String(Math.round(s / 3600))}h`; 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 // Sub-component: Jail overview section
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function JailOverviewSection(): React.JSX.Element { function JailOverviewSection(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const navigate = useNavigate();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails(); useJails();
const [opError, setOpError] = useState<string | null>(null); 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 => { const handle = (fn: () => Promise<void>): void => {
setOpError(null); setOpError(null);
fn().catch((err: unknown) => { fn().catch((err: unknown) => {

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