58 Commits

Author SHA1 Message Date
d909f93efc chore: release v0.9.12 2026-03-22 21:47:40 +01:00
965cdd765b Fix useJailDetail mocked command responses to match JailCommandResponse type 2026-03-22 21:46:01 +01:00
0663740b08 chore: release v0.9.11 2026-03-22 21:43:31 +01:00
29587f2353 backup 2026-03-22 21:42:19 +01:00
798ed08ddd Refactor service status response: migrate bangui_version into version field 2026-03-22 21:42:08 +01:00
ed184f1c84 Fix config status and missing historical filter imports
1) Added _get_active_jail_names import in jail_config_service 2) Added _get_active_jail_names and _parse_jails_sync imports in filter_config_service and resolved constants/exceptions 3) Added bangui_version=__version__ in config_service.get_service_status and tests
2026-03-22 20:54:44 +01:00
8e1b4fa978 test: add regression test for 500 errors 2026-03-22 20:33:43 +01:00
e604e3aadf chore: commit changes from Copilot session 2026-03-22 20:33:06 +01:00
cf721513e8 Fix history origin filter path and add regression tests 2026-03-22 20:32:40 +01:00
a32cc82851 fix(blocklists): load useBlocklistStyles from blocklistStyles instead of commonStyles 2026-03-22 18:17:47 +01:00
26af69e2a3 Merge branch 'main' of https://git.lpl-mind.de/lukas.pupkalipinski/BanGUI 2026-03-22 14:31:20 +01:00
00e702a2c0 backup 2026-03-22 14:30:02 +01:00
ee73373111 backup 2026-03-22 14:24:32 +01:00
a1f97bd78f Refactor BlocklistsPage into section components and fix frontend lint issues 2026-03-22 14:24:32 +01:00
99fbddb0e7 chore: verify and finalize task completion for existing refactor tasks 2026-03-22 14:24:32 +01:00
b15629a078 Mark Task 1 as verified done and update notes 2026-03-22 14:24:32 +01:00
136f21ecbe Standardise frontend hook fetch error handling and mark Task 12 done 2026-03-22 14:24:32 +01:00
bf2abda595 chore: commit local changes 2026-03-22 14:24:32 +01:00
335f89c554 Docs: mark Task 8/9 completed and update architecture docs 2026-03-22 14:24:28 +01:00
05dc9fa1e3 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-22 14:24:28 +01:00
471eed9664 Rename file_config_service to raw_config_io_service and update references 2026-03-22 14:24:28 +01:00
1f272dc348 Refactor config regex/log preview into dedicated log_service 2026-03-22 14:24:28 +01:00
f9cec2a975 Mark Task 4 (Split config_file_service) as completed 2026-03-22 14:24:28 +01:00
cc235b95c6 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-22 14:24:28 +01:00
29415da421 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-22 14:24:28 +01:00
8a6bcc4d94 Refactor frontend date formatting helpers and mark Task 10 done 2026-03-22 14:24:28 +01:00
a442836c5c refactor: complete Task 2/3 geo decouple + exceptions centralization; mark as done 2026-03-22 14:24:25 +01:00
3aba2b6446 backup 2026-03-22 14:24:25 +01:00
28a7610276 Refactor frontend API calls into hooks and complete task states 2026-03-22 14:24:25 +01:00
d30d138146 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-22 14:24:25 +01:00
8c4fe767de 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-22 14:24:25 +01:00
52b0936200 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-22 14:24:24 +01:00
1c0bac1353 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-22 14:24:24 +01:00
bdcdd5d672 Fix geo_re_resolve async mocks and mark tasks complete 2026-03-22 14:24:24 +01:00
482399c9e2 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-22 14:24:24 +01:00
ce59a66973 Move conffile_parser from services to utils 2026-03-22 14:24:24 +01:00
dfbe126368 Fix ban_service typing by replacing Any with GeoEnricher and GeoInfo 2026-03-22 14:24:24 +01:00
c9e688cc52 Refactor geo cache persistence into repository + remove raw SQL from tasks/main, update task list 2026-03-22 14:24:24 +01:00
1ce5da9e23 Refactor blocklist log retrieval via service layer and add fail2ban DB repo 2026-03-22 14:24:24 +01:00
93f0feabde Refactor geo re-resolve to use geo_cache repo and move data-access out of router 2026-03-22 14:24:24 +01:00
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
50 changed files with 1186 additions and 153 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
@@ -361,7 +362,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

@@ -1001,7 +1001,7 @@ class ServiceStatusResponse(BaseModel):
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
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="BanGUI application version (or None when offline).")
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

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

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,6 +70,7 @@ async def get_server_status(
"server_status", "server_status",
ServerStatus(online=False), ServerStatus(online=False),
) )
cached.version = __version__
return ServerStatusResponse(status=cached) return ServerStatusResponse(status=cached)

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

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

@@ -17,16 +17,21 @@ from pathlib import Path
import structlog import structlog
from app.exceptions import FilterInvalidRegexError
from app.models.config import ( from app.models.config import (
AssignFilterRequest,
FilterConfig, FilterConfig,
FilterConfigUpdate, FilterConfigUpdate,
FilterCreateRequest, FilterCreateRequest,
FilterListResponse, FilterListResponse,
FilterUpdateRequest, FilterUpdateRequest,
AssignFilterRequest,
) )
from app.exceptions import FilterInvalidRegexError, JailNotFoundError from app.services.config_file_service import _TRUE_VALUES, ConfigWriteError, JailNotFoundInConfigError
from app.utils import conffile_parser 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 from app.utils.jail_utils import reload_jails
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -90,6 +95,7 @@ class JailNameError(Exception):
"""Raised when a jail name contains invalid characters.""" """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}$") _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: if TYPE_CHECKING:
from app.models.geo import GeoEnricher from app.models.geo import GeoEnricher
from app.models.ban import TIME_RANGE_SECONDS, TimeRange from app.models.ban import TIME_RANGE_SECONDS, BanOrigin, TimeRange
from app.models.history import ( from app.models.history import (
HistoryBanItem, HistoryBanItem,
HistoryListResponse, HistoryListResponse,
@@ -62,6 +62,7 @@ async def list_history(
range_: TimeRange | None = None, range_: TimeRange | None = None,
jail: str | None = None, jail: str | None = None,
ip_filter: str | None = None, ip_filter: str | None = None,
origin: BanOrigin | None = None,
page: int = 1, page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE, page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: GeoEnricher | None = None, geo_enricher: GeoEnricher | None = None,
@@ -108,6 +109,7 @@ async def list_history(
since=since, since=since,
jail=jail, jail=jail,
ip_filter=ip_filter, ip_filter=ip_filter,
origin=origin,
page=page, page=page,
page_size=effective_page_size, page_size=effective_page_size,
) )

View File

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

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

@@ -0,0 +1,276 @@
"""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,3 +136,32 @@ 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") 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 len(history_for_ip) == 1
assert history_for_ip[0].ip == "2.2.2.2" 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,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
@@ -1999,7 +2001,7 @@ class TestGetServiceStatus:
def _mock_status(self, online: bool = True) -> ServiceStatusResponse: def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
return ServiceStatusResponse( return ServiceStatusResponse(
online=online, online=online,
version="1.0.0" if online else None, 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 +2020,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["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 +2034,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["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,7 @@ class TestDashboardStatus:
body = response.json() body = response.json()
assert "status" in body assert "status" in body
status = body["status"] status = body["status"]
assert "online" in status assert "online" in status
assert "version" in status assert "version" in status
@@ -163,10 +166,11 @@ 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 status["online"] is True assert status["online"] is True
assert status["version"] == "1.0.2" assert status["version"] == app.__version__
assert status["active_jails"] == 2 assert status["active_jails"] == 2
assert status["total_bans"] == 10 assert status["total_bans"] == 10
assert status["total_failures"] == 5 assert status["total_failures"] == 5
@@ -177,10 +181,11 @@ 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 status["online"] is False assert status["online"] is False
assert status["version"] is None assert status["version"] == app.__version__
assert status["active_jails"] == 0 assert status["active_jails"] == 0
assert status["total_bans"] == 0 assert status["total_bans"] == 0
assert status["total_failures"] == 0 assert status["total_failures"] == 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

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -256,6 +257,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
@@ -727,8 +749,10 @@ class TestGetServiceStatus:
probe_fn=AsyncMock(return_value=online_status), probe_fn=AsyncMock(return_value=online_status),
) )
from app import __version__
assert result.online is True assert result.online is True
assert result.version == "1.0.0" assert result.version == __version__
assert result.jail_count == 2 assert result.jail_count == 2
assert result.total_bans == 5 assert result.total_bans == 5
assert result.total_failures == 3 assert result.total_failures == 3
@@ -750,3 +774,62 @@ class TestGetServiceStatus:
assert result.jail_count == 0 assert result.jail_count == 0
assert result.log_level == "UNKNOWN" assert result.log_level == "UNKNOWN"
assert result.log_target == "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,6 +179,19 @@ class TestListHistory:
# 2 sshd bans for 1.2.3.4 # 2 sshd bans for 1.2.3.4
assert result.total == 2 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: async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
"""Filtering by a non-existent IP returns an empty result set.""" """Filtering by a non-existent IP returns an empty result set."""
with patch( with patch(

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "bangui-frontend", "name": "bangui-frontend",
"private": true, "private": true,
"version": "0.9.4", "version": "0.9.12",
"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

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

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";
@@ -48,6 +49,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,
},
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -56,6 +79,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;
@@ -65,6 +89,7 @@ interface GeoLayerProps {
function GeoLayer({ function GeoLayer({
countries, countries,
countryNames,
selectedCountry, selectedCountry,
onSelectCountry, onSelectCountry,
thresholdLow, thresholdLow,
@@ -74,6 +99,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);
@@ -134,6 +170,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}
@@ -177,6 +237,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,
)}
</> </>
); );
} }
@@ -188,6 +264,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. */
@@ -202,6 +280,7 @@ export interface WorldMapProps {
export function WorldMap({ export function WorldMap({
countries, countries,
countryNames,
selectedCountry, selectedCountry,
onSelectCountry, onSelectCountry,
thresholdLow = 20, thresholdLow = 20,
@@ -285,6 +364,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

@@ -101,6 +101,23 @@ describe("ServerStatusBar", () => {
expect(screen.getByText("v1.2.3")).toBeInTheDocument(); 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", () => { it("does not render the version element when version is null", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: { status: {

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

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

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

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

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,
@@ -43,8 +42,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
@@ -55,13 +56,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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -380,7 +374,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>({
@@ -396,11 +391,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));
@@ -451,24 +447,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>
@@ -505,7 +493,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

@@ -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 { useMapColorThresholds } from "../hooks/useMapColorThresholds"; import { useMapColorThresholds } from "../hooks/useMapColorThresholds";
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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -133,41 +119,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();
@@ -175,7 +140,7 @@ export function MapPage(): React.JSX.Element {
disabled={loading} disabled={loading}
title="Refresh" title="Refresh"
/> />
</Toolbar> </div>
</div> </div>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
@@ -199,6 +164,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

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

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

@@ -1,18 +1,22 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { resolve } from "path"; import { resolve } from "path";
import { readFileSync } from "node:fs"; import { readFileSync, existsSync } from "node:fs";
const pkg = JSON.parse( let appVersion = "0.0.0";
readFileSync(resolve(__dirname, "package.json"), "utf-8"), const versionFile = resolve(__dirname, "../Docker/VERSION");
) as { version: string }; if (existsSync(versionFile)) {
appVersion = readFileSync(versionFile, "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