29 Commits

Author SHA1 Message Date
a92a8220c2 backup 2026-03-22 14:20:41 +01:00
9c5b7ba091 Refactor BlocklistsPage into section components and fix frontend lint issues 2026-03-22 14:08:20 +01:00
20dd890746 chore: verify and finalize task completion for existing refactor tasks 2026-03-22 13:32:46 +01:00
7306b98a54 Mark Task 1 as verified done and update notes 2026-03-22 13:13:29 +01:00
e0c21dcc10 Standardise frontend hook fetch error handling and mark Task 12 done 2026-03-22 10:17:15 +01:00
e2876fc35c chore: commit local changes 2026-03-22 10:07:44 +01:00
96370ee6aa Docs: mark Task 8/9 completed and update architecture docs 2026-03-22 10:06:00 +01:00
2022bcde99 Fix backend tests by using per-test temp config dir, align router mocks to service modules, fix log tail helper reference, and add JailNotFoundError.name 2026-03-21 19:43:59 +01:00
1f4ee360f6 Rename file_config_service to raw_config_io_service and update references 2026-03-21 18:56:02 +01:00
9646b1c119 Refactor config regex/log preview into dedicated log_service 2026-03-21 18:46:29 +01:00
2e3ac5f005 Mark Task 4 (Split config_file_service) as completed 2026-03-21 17:49:53 +01:00
90e42e96b4 Split config_file_service.py into three specialized service modules
Extract jail, filter, and action configuration management into separate
domain-focused service modules:

- jail_config_service.py: Jail activation, deactivation, validation, rollback
- filter_config_service.py: Filter discovery, CRUD, assignment to jails
- action_config_service.py: Action discovery, CRUD, assignment to jails

Benefits:
- Reduces monolithic 3100-line module into three focused modules
- Improves readability and maintainability per domain
- Clearer separation of concerns following single responsibility principle
- Easier to test domain-specific functionality in isolation
- Reduces coupling - each service only depends on its needed utilities

Changes:
- Create three new service modules under backend/app/services/
- Update backend/app/routers/config.py to import from new modules
- Update exception and function imports to source from appropriate service
- Update Architecture.md to reflect new service organization
- All existing tests continue to pass with new module structure

Relates to Task 4 of refactoring backlog in Docs/Tasks.md
2026-03-21 17:49:32 +01:00
aff67b3a78 Add ErrorBoundary component to catch render-time errors
- Create ErrorBoundary component to handle React render errors
- Wrap App component with ErrorBoundary for global error handling
- Add comprehensive tests for ErrorBoundary functionality
- Show fallback UI with error message when errors occur
2026-03-21 17:26:40 +01:00
ffaa5c3adb Refactor frontend date formatting helpers and mark Task 10 done 2026-03-21 17:25:45 +01:00
5a49106f4d refactor: complete Task 2/3 geo decouple + exceptions centralization; mark as done 2026-03-21 17:15:02 +01:00
452901913f backup 2026-03-20 15:18:55 +01:00
25b4ebbd96 Refactor frontend API calls into hooks and complete task states 2026-03-20 15:18:04 +01:00
7627ae7edb Add jail control actions to useJailDetail hook
Implement TASK F-2: Wrap JailDetailPage jail-control API calls in a hook.

Changes:
- Add start(), stop(), reload(), and setIdle() methods to useJailDetail hook
- Update JailDetailPage to use hook control methods instead of direct API imports
- Update error handling to remove dependency on ApiError type
- Add comprehensive tests for new control methods (8 tests)
- Update existing test to include new hook methods in mock

The control methods handle refetching jail data after each operation,
consistent with the pattern used in useJails hook.
2026-03-20 13:58:01 +01:00
377cc7ac88 chore: add root pyproject.toml for ruff configuration
Centralizes ruff linter configuration at project root with consistent
line length (120 chars), Python 3.12 target, and exclusions for
external dependencies and build artifacts.
2026-03-20 13:44:30 +01:00
77711e202d chore: update frontend package-lock version to 0.9.4 2026-03-20 13:44:25 +01:00
3568e9caf3 fix: add console.warn logging when setup status check fails
Logs a warning when the initial setup status request fails, allowing
operators to diagnose issues during the setup phase. The form remains
visible while the error is logged for debugging purposes.
2026-03-20 13:44:21 +01:00
250bb1a2e5 refactor: improve backend type safety and import organization
- Add TYPE_CHECKING guards for runtime-expensive imports (aiohttp, aiosqlite)
- Reorganize imports to follow PEP 8 conventions
- Convert TypeAlias to modern PEP 695 type syntax (where appropriate)
- Use Sequence/Mapping from collections.abc for type hints (covariant)
- Replace string literals with cast() for improved type inference
- Fix casting of Fail2BanResponse and TypedDict patterns
- Add IpLookupResult TypedDict for precise return type annotation
- Reformat overlong lines for readability (120 char limit)
- Add asyncio_mode and filterwarnings to pytest config
- Update test fixtures with improved type hints

This improves mypy type checking and makes type relationships explicit.
2026-03-20 13:44:14 +01:00
6515164d53 Fix geo_re_resolve async mocks and mark tasks complete 2026-03-17 18:54:25 +01:00
25d43ffb96 Remove Any type annotations from config_service.py
Replace Any with typed aliases (Fail2BanToken/Fail2BanCommand/Fail2BanResponse), add typed helper, and update task list.
2026-03-17 11:42:46 +01:00
29762664d7 Move conffile_parser from services to utils 2026-03-17 11:11:08 +01:00
a2b8e14cbc Fix ban_service typing by replacing Any with GeoEnricher and GeoInfo 2026-03-17 10:33:39 +01:00
68114924bb Refactor geo cache persistence into repository + remove raw SQL from tasks/main, update task list 2026-03-17 09:18:05 +01:00
7866f9cbb2 Refactor blocklist log retrieval via service layer and add fail2ban DB repo 2026-03-17 08:58:04 +01:00
dcd8059b27 Refactor geo re-resolve to use geo_cache repo and move data-access out of router 2026-03-16 21:12:07 +01:00
50 changed files with 156 additions and 1190 deletions

View File

@@ -1 +1 @@
v0.9.12
v0.9.4

View File

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

View File

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

View File

@@ -69,24 +69,18 @@ sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_P
echo "frontend/package.json version updated → ${FRONT_VERSION}"
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"
# ---------------------------------------------------------------------------
# Git tag (local only; push after container build)
# Git tag
# ---------------------------------------------------------------------------
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 git commits & tag
# ---------------------------------------------------------------------------
git push origin HEAD
git push origin "${NEW_TAG}"
echo "Git commit and tag ${NEW_TAG} pushed."
echo "Git tag ${NEW_TAG} created and pushed."
# ---------------------------------------------------------------------------
# Push
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"

View File

@@ -1,68 +1 @@
"""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()
"""BanGUI backend application package."""

View File

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

View File

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

View File

@@ -294,7 +294,6 @@ async def get_history_page(
since: int | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[HistoryRecord], int]:
@@ -315,12 +314,6 @@ async def get_history_page(
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
origin_clause, origin_params = _origin_sql_filter(origin)
if origin_clause:
origin_clause_clean = origin_clause.removeprefix(" AND ")
wheres.append(origin_clause_clean)
params.extend(origin_params)
where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else ""
effective_page_size: int = page_size

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio
import contextlib
import re
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import TYPE_CHECKING, TypeVar, cast
@@ -23,12 +24,8 @@ import structlog
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanResponse, Fail2BanToken
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
import aiosqlite
from app import __version__
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
from app.models.config import (
AddLogPathRequest,
BantimeEscalation,
@@ -47,13 +44,11 @@ from app.models.config import (
RegexTestResponse,
ServiceStatusResponse,
)
from app.exceptions import ConfigOperationError, ConfigValidationError, JailNotFoundError
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.log_utils import preview_log as util_preview_log
from app.utils.log_utils import test_regex as util_test_regex
from app.utils.log_utils import preview_log as util_preview_log, test_regex as util_test_regex
from app.utils.setup_utils import (
get_map_color_thresholds as util_get_map_color_thresholds,
)
from app.utils.setup_utils import (
set_map_color_thresholds as util_set_map_color_thresholds,
)
@@ -819,7 +814,7 @@ async def get_service_status(
return ServiceStatusResponse(
online=server_status.online,
version=__version__,
version=server_status.version,
jail_count=server_status.active_jails,
total_bans=server_status.total_bans,
total_failures=server_status.total_failures,

View File

@@ -17,21 +17,16 @@ from pathlib import Path
import structlog
from app.exceptions import FilterInvalidRegexError
from app.models.config import (
AssignFilterRequest,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
FilterListResponse,
FilterUpdateRequest,
AssignFilterRequest,
)
from app.services.config_file_service import _TRUE_VALUES, ConfigWriteError, JailNotFoundInConfigError
from app.exceptions import FilterInvalidRegexError, JailNotFoundError
from app.utils import conffile_parser
from app.utils.config_file_utils import (
_get_active_jail_names,
_parse_jails_sync,
)
from app.utils.jail_utils import reload_jails
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -95,7 +90,6 @@ class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")

View File

@@ -18,7 +18,7 @@ import structlog
if TYPE_CHECKING:
from app.models.geo import GeoEnricher
from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
@@ -62,7 +62,6 @@ async def list_history(
range_: TimeRange | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: GeoEnricher | None = None,
@@ -109,7 +108,6 @@ async def list_history(
since=since,
jail=jail,
ip_filter=ip_filter,
origin=origin,
page=page,
page_size=effective_page_size,
)

View File

@@ -26,17 +26,22 @@ from app.models.config import (
InactiveJail,
InactiveJailListResponse,
JailActivationResponse,
JailValidationIssue,
JailValidationResult,
RollbackResponse,
)
from app.utils.config_file_utils import (
_build_inactive_jail,
_get_active_jail_names,
_ordered_config_files,
_parse_jails_sync,
_validate_jail_config_sync,
)
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.jail_utils import reload_jails
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
Fail2BanResponse,
)
log: structlog.stdlib.BoundLogger = structlog.get_logger()

View File

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

View File

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

View File

@@ -1,276 +0,0 @@
"""Regression tests for the four 500-error bugs discovered on 2026-03-22.
Each test targets the exact code path that caused a 500 Internal Server Error.
These tests call the **real** service/repository functions (not the router)
so they fail even if the route layer is mocked in router-level tests.
Bugs covered:
1. ``list_history`` rejected the ``origin`` keyword argument (TypeError).
2. ``jail_config_service`` used ``_get_active_jail_names`` without importing it.
3. ``filter_config_service`` used ``_parse_jails_sync`` / ``_get_active_jail_names``
without importing them.
4. ``config_service.get_service_status`` omitted the required ``bangui_version``
field from the ``ServiceStatusResponse`` constructor (Pydantic ValidationError).
"""
from __future__ import annotations
import inspect
import json
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import aiosqlite
import pytest
# ── Bug 1 ─────────────────────────────────────────────────────────────────
class TestHistoryOriginParameter:
"""Bug 1: ``origin`` parameter must be threaded through service → repo."""
# -- Service layer --
async def test_list_history_accepts_origin_kwarg(self) -> None:
"""``history_service.list_history()`` must accept an ``origin`` keyword."""
from app.services import history_service
sig = inspect.signature(history_service.list_history)
assert "origin" in sig.parameters, (
"list_history() is missing the 'origin' parameter — "
"the router passes origin=… which would cause a TypeError"
)
async def test_list_history_forwards_origin_to_repo(
self, tmp_path: Path
) -> None:
"""``list_history(origin='blocklist')`` must forward origin to the DB repo."""
from app.services import history_service
db_path = str(tmp_path / "f2b.db")
async with aiosqlite.connect(db_path) as db:
await db.execute(
"CREATE TABLE jails (name TEXT, enabled INTEGER DEFAULT 1)"
)
await db.execute(
"CREATE TABLE bans "
"(jail TEXT, ip TEXT, timeofban INTEGER, bantime INTEGER, "
"bancount INTEGER DEFAULT 1, data JSON)"
)
await db.execute(
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
("blocklist-import", "10.0.0.1", int(time.time()), 3600, 1, "{}"),
)
await db.execute(
"INSERT INTO bans VALUES (?, ?, ?, ?, ?, ?)",
("sshd", "10.0.0.2", int(time.time()), 3600, 1, "{}"),
)
await db.commit()
with patch(
"app.services.history_service.get_fail2ban_db_path",
new=AsyncMock(return_value=db_path),
):
result = await history_service.list_history(
"fake_socket", origin="blocklist"
)
assert all(
item.jail == "blocklist-import" for item in result.items
), "origin='blocklist' must filter to blocklist-import jail only"
# -- Repository layer --
async def test_get_history_page_accepts_origin_kwarg(self) -> None:
"""``fail2ban_db_repo.get_history_page()`` must accept ``origin``."""
from app.repositories import fail2ban_db_repo
sig = inspect.signature(fail2ban_db_repo.get_history_page)
assert "origin" in sig.parameters, (
"get_history_page() is missing the 'origin' parameter"
)
async def test_get_history_page_filters_by_origin(
self, tmp_path: Path
) -> None:
"""``get_history_page(origin='selfblock')`` excludes blocklist-import."""
from app.repositories import fail2ban_db_repo
db_path = str(tmp_path / "f2b.db")
async with aiosqlite.connect(db_path) as db:
await db.execute(
"CREATE TABLE bans "
"(jail TEXT, ip TEXT, timeofban INTEGER, bancount INTEGER, data TEXT)"
)
await db.executemany(
"INSERT INTO bans VALUES (?, ?, ?, ?, ?)",
[
("blocklist-import", "10.0.0.1", 100, 1, "{}"),
("sshd", "10.0.0.2", 200, 1, "{}"),
("sshd", "10.0.0.3", 300, 1, "{}"),
],
)
await db.commit()
rows, total = await fail2ban_db_repo.get_history_page(
db_path=db_path, origin="selfblock"
)
assert total == 2
assert all(r.jail != "blocklist-import" for r in rows)
# ── Bug 2 ─────────────────────────────────────────────────────────────────
class TestJailConfigImports:
"""Bug 2: ``jail_config_service`` must import ``_get_active_jail_names``."""
async def test_get_active_jail_names_is_importable(self) -> None:
"""The module must successfully import ``_get_active_jail_names``."""
import app.services.jail_config_service as mod
assert hasattr(mod, "_get_active_jail_names") or callable(
getattr(mod, "_get_active_jail_names", None)
), (
"_get_active_jail_names is not available in jail_config_service — "
"any call site will raise NameError → 500"
)
async def test_list_inactive_jails_does_not_raise_name_error(
self, tmp_path: Path
) -> None:
"""``list_inactive_jails`` must not crash with NameError."""
from app.services import jail_config_service
config_dir = str(tmp_path / "fail2ban")
Path(config_dir).mkdir()
(Path(config_dir) / "jail.conf").write_text("[DEFAULT]\n")
with patch(
"app.services.jail_config_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await jail_config_service.list_inactive_jails(
config_dir, "/fake/socket"
)
assert result.total >= 0
# ── Bug 3 ─────────────────────────────────────────────────────────────────
class TestFilterConfigImports:
"""Bug 3: ``filter_config_service`` must import ``_parse_jails_sync``
and ``_get_active_jail_names``."""
async def test_parse_jails_sync_is_available(self) -> None:
"""``_parse_jails_sync`` must be resolvable at module scope."""
import app.services.filter_config_service as mod
assert hasattr(mod, "_parse_jails_sync"), (
"_parse_jails_sync is not available in filter_config_service — "
"list_filters() will raise NameError → 500"
)
async def test_get_active_jail_names_is_available(self) -> None:
"""``_get_active_jail_names`` must be resolvable at module scope."""
import app.services.filter_config_service as mod
assert hasattr(mod, "_get_active_jail_names"), (
"_get_active_jail_names is not available in filter_config_service — "
"list_filters() will raise NameError → 500"
)
async def test_list_filters_does_not_raise_name_error(
self, tmp_path: Path
) -> None:
"""``list_filters`` must not crash with NameError."""
from app.services import filter_config_service
config_dir = str(tmp_path / "fail2ban")
filter_d = Path(config_dir) / "filter.d"
filter_d.mkdir(parents=True)
# Create a minimal filter file so _parse_filters_sync has something to scan.
(filter_d / "sshd.conf").write_text(
"[Definition]\nfailregex = ^Failed password\n"
)
with (
patch(
"app.services.filter_config_service._parse_jails_sync",
return_value=({}, {}),
),
patch(
"app.services.filter_config_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
):
result = await filter_config_service.list_filters(
config_dir, "/fake/socket"
)
assert result.total >= 0
# ── Bug 4 ─────────────────────────────────────────────────────────────────
class TestServiceStatusBanguiVersion:
"""Bug 4: ``get_service_status`` must include application version
in the ``version`` field of the ``ServiceStatusResponse``."""
async def test_online_response_contains_bangui_version(self) -> None:
"""The returned model must contain the ``bangui_version`` field."""
from app.models.server import ServerStatus
from app.services import config_service
import app
online_status = ServerStatus(
online=True,
version="1.0.0",
active_jails=2,
total_bans=5,
total_failures=3,
)
async def _send(command: list[Any]) -> Any:
key = "|".join(str(c) for c in command)
if key == "get|loglevel":
return (0, "INFO")
if key == "get|logtarget":
return (0, "/var/log/fail2ban.log")
return (0, None)
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
result = await config_service.get_service_status(
"/fake/socket",
probe_fn=AsyncMock(return_value=online_status),
)
assert result.version == app.__version__, (
"ServiceStatusResponse must expose BanGUI version in version field"
)
async def test_offline_response_contains_bangui_version(self) -> None:
"""Even when fail2ban is offline, ``bangui_version`` must be present."""
from app.models.server import ServerStatus
from app.services import config_service
import app
offline_status = ServerStatus(online=False)
result = await config_service.get_service_status(
"/fake/socket",
probe_fn=AsyncMock(return_value=offline_status),
)
assert result.version == app.__version__

View File

@@ -136,32 +136,3 @@ async def test_get_history_page_and_for_ip(tmp_path: Path) -> None:
history_for_ip = await fail2ban_db_repo.get_history_for_ip(db_path=db_path, ip="2.2.2.2")
assert len(history_for_ip) == 1
assert history_for_ip[0].ip == "2.2.2.2"
@pytest.mark.asyncio
async def test_get_history_page_origin_filter(tmp_path: Path) -> None:
db_path = str(tmp_path / "fail2ban.db")
async with aiosqlite.connect(db_path) as db:
await _create_bans_table(db)
await db.executemany(
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
[
("jail1", "1.1.1.1", 100, 1, "{}"),
("blocklist-import", "2.2.2.2", 200, 1, "{}"),
],
)
await db.commit()
page, total = await fail2ban_db_repo.get_history_page(
db_path=db_path,
since=None,
jail=None,
ip_filter=None,
origin="selfblock",
page=1,
page_size=10,
)
assert total == 1
assert len(page) == 1
assert page[0].ip == "1.1.1.1"

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -257,27 +256,6 @@ class TestUpdateJailConfig:
assert "bantime" in keys
assert "maxretry" in keys
async def test_ignores_backend_field(self) -> None:
"""update_jail_config does not send a set command for backend."""
sent_commands: list[list[Any]] = []
async def _send(command: list[Any]) -> Any:
sent_commands.append(command)
return (0, "OK")
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
from app.models.config import JailConfigUpdate
update = JailConfigUpdate(backend="polling")
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
await config_service.update_jail_config(_SOCKET, "sshd", update)
keys = [cmd[2] for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
assert "backend" not in keys
async def test_raises_validation_error_on_bad_regex(self) -> None:
"""update_jail_config raises ConfigValidationError for invalid regex."""
from app.models.config import JailConfigUpdate
@@ -749,10 +727,8 @@ class TestGetServiceStatus:
probe_fn=AsyncMock(return_value=online_status),
)
from app import __version__
assert result.online is True
assert result.version == __version__
assert result.version == "1.0.0"
assert result.jail_count == 2
assert result.total_bans == 5
assert result.total_failures == 3
@@ -774,62 +750,3 @@ class TestGetServiceStatus:
assert result.jail_count == 0
assert result.log_level == "UNKNOWN"
assert result.log_target == "UNKNOWN"
@pytest.mark.asyncio
class TestConfigModuleIntegration:
async def test_jail_config_service_list_inactive_jails_uses_imports(self, tmp_path: Any) -> None:
from app.services.jail_config_service import list_inactive_jails
# Arrange: fake parse_jails output with one active and one inactive
def fake_parse_jails_sync(path: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
return (
{
"sshd": {
"enabled": "true",
"filter": "sshd",
"logpath": "/var/log/auth.log",
},
"apache-auth": {
"enabled": "false",
"filter": "apache-auth",
"logpath": "/var/log/apache2/error.log",
},
},
{
"sshd": str(path / "jail.conf"),
"apache-auth": str(path / "jail.conf"),
},
)
with patch(
"app.services.jail_config_service._parse_jails_sync",
new=fake_parse_jails_sync,
), patch(
"app.services.jail_config_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
names = {j.name for j in result.jails}
assert "apache-auth" in names
assert "sshd" not in names
async def test_filter_config_service_list_filters_uses_imports(self, tmp_path: Any) -> None:
from app.services.filter_config_service import list_filters
# Arrange minimal filter and jail config files
filter_d = tmp_path / "filter.d"
filter_d.mkdir(parents=True)
(filter_d / "sshd.conf").write_text("[Definition]\nfailregex = ^%(__prefix_line)s.*$\n")
(tmp_path / "jail.conf").write_text("[sshd]\nfilter = sshd\nenabled = true\n")
with patch(
"app.services.filter_config_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_filters(str(tmp_path), "/fake.sock")
assert result.total == 1
assert result.filters[0].name == "sshd"
assert result.filters[0].active is True

View File

@@ -179,19 +179,6 @@ class TestListHistory:
# 2 sshd bans for 1.2.3.4
assert result.total == 2
async def test_origin_filter_selfblock(self, f2b_db_path: str) -> None:
"""Origin filter should include only selfblock entries."""
with patch(
"app.services.history_service.get_fail2ban_db_path",
new=AsyncMock(return_value=f2b_db_path),
):
result = await history_service.list_history(
"fake_socket", origin="selfblock"
)
assert result.total == 4
assert all(item.jail != "blocklist-import" for item in result.items)
async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
"""Filtering by a non-existent IP returns an empty result set."""
with patch(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,7 @@ export function ServerStatusBar(): React.JSX.Element {
{/* Version */}
{/* ---------------------------------------------------------------- */}
{status?.version != null && (
<Tooltip content="BanGUI version" relationship="description">
<Tooltip content="fail2ban daemon version" relationship="description">
<Text size={200} className={styles.statValue}>
v{status.version}
</Text>

View File

@@ -7,7 +7,6 @@
* country filters the companion table.
*/
import { createPortal } from "react-dom";
import { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import { Button, makeStyles, tokens } from "@fluentui/react-components";
@@ -49,28 +48,6 @@ const useStyles = makeStyles({
gap: tokens.spacingVerticalXS,
zIndex: 10,
},
tooltip: {
position: "fixed",
zIndex: 9999,
pointerEvents: "none",
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
borderRadius: tokens.borderRadiusSmall,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXXS,
boxShadow: tokens.shadow4,
},
tooltipCountry: {
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1,
},
tooltipCount: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground2,
},
});
// ---------------------------------------------------------------------------
@@ -79,7 +56,6 @@ const useStyles = makeStyles({
interface GeoLayerProps {
countries: Record<string, number>;
countryNames?: Record<string, string>;
selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void;
thresholdLow: number;
@@ -89,7 +65,6 @@ interface GeoLayerProps {
function GeoLayer({
countries,
countryNames,
selectedCountry,
onSelectCountry,
thresholdLow,
@@ -99,17 +74,6 @@ function GeoLayer({
const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL });
const [tooltip, setTooltip] = useState<
| {
cc: string;
count: number;
name: string;
x: number;
y: number;
}
| null
>(null);
const handleClick = useCallback(
(cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc);
@@ -170,30 +134,6 @@ function GeoLayer({
handleClick(cc);
}
}}
onMouseEnter={(e): void => {
if (!cc) return;
setTooltip({
cc,
count,
name: countryNames?.[cc] ?? cc,
x: e.clientX,
y: e.clientY,
});
}}
onMouseMove={(e): void => {
setTooltip((current) =>
current
? {
...current,
x: e.clientX,
y: e.clientY,
}
: current,
);
}}
onMouseLeave={(): void => {
setTooltip(null);
}}
>
<Geography
geography={geo}
@@ -237,22 +177,6 @@ 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,
)}
</>
);
}
@@ -264,8 +188,6 @@ function GeoLayer({
export interface WorldMapProps {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** Optional mapping from country code to display name. */
countryNames?: Record<string, string>;
/** Currently selected country filter (null means no filter). */
selectedCountry: string | null;
/** Called when the user clicks a country or deselects. */
@@ -280,7 +202,6 @@ export interface WorldMapProps {
export function WorldMap({
countries,
countryNames,
selectedCountry,
onSelectCountry,
thresholdLow = 20,
@@ -364,7 +285,6 @@ export function WorldMap({
>
<GeoLayer
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow}

View File

@@ -101,23 +101,6 @@ describe("ServerStatusBar", () => {
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
});
it("does not render a separate BanGUI version badge", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.2.3",
active_jails: 1,
total_bans: 0,
total_failures: 0,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.queryByText("BanGUI v9.9.9")).toBeNull();
});
it("does not render the version element when version is null", () => {
mockedUseServerStatus.mockReturnValue({
status: {

View File

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

View File

@@ -1,84 +0,0 @@
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

@@ -1,50 +0,0 @@
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 version in the service health panel", async () => {
mockedFetchServiceStatus.mockResolvedValue({
online: true,
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 version.
const versionLabel = await screen.findByText("Version");
expect(versionLabel).toBeInTheDocument();
const versionCard = versionLabel.closest("div");
expect(versionCard).toHaveTextContent("1.2.3");
});
});

View File

@@ -36,7 +36,7 @@ describe("useJailDetail control methods", () => {
});
it("calls start() and refetches jail data", async () => {
vi.mocked(jailsApi.startJail).mockResolvedValue({ message: "jail started", jail: "sshd" });
vi.mocked(jailsApi.startJail).mockResolvedValue(undefined);
const { result } = renderHook(() => useJailDetail("sshd"));
@@ -58,7 +58,7 @@ describe("useJailDetail control methods", () => {
});
it("calls stop() and refetches jail data", async () => {
vi.mocked(jailsApi.stopJail).mockResolvedValue({ message: "jail stopped", jail: "sshd" });
vi.mocked(jailsApi.stopJail).mockResolvedValue(undefined);
const { result } = renderHook(() => useJailDetail("sshd"));
@@ -77,7 +77,7 @@ describe("useJailDetail control methods", () => {
});
it("calls reload() and refetches jail data", async () => {
vi.mocked(jailsApi.reloadJail).mockResolvedValue({ message: "jail reloaded", jail: "sshd" });
vi.mocked(jailsApi.reloadJail).mockResolvedValue(undefined);
const { result } = renderHook(() => useJailDetail("sshd"));
@@ -96,7 +96,7 @@ describe("useJailDetail control methods", () => {
});
it("calls setIdle() with correct parameter and refetches jail data", async () => {
vi.mocked(jailsApi.setJailIdle).mockResolvedValue({ message: "jail idle toggled", jail: "sshd" });
vi.mocked(jailsApi.setJailIdle).mockResolvedValue(undefined);
const { result } = renderHook(() => useJailDetail("sshd"));

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
import { useCallback, useState } from "react";
import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
import { useBlocklistStyles } from "../components/blocklist/blocklistStyles";
import { useBlocklistStyles } from "../theme/commonStyles";
import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,58 +0,0 @@
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

@@ -1,74 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -659,7 +659,7 @@ export interface Fail2BanLogResponse {
export interface ServiceStatusResponse {
/** Whether fail2ban is reachable via its socket. */
online: boolean;
/** BanGUI application version (or null when offline). */
/** fail2ban version string, or null when offline. */
version: string | null;
/** Number of currently active jails. */
jail_count: number;

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
[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