Stage 11: polish, cross-cutting concerns & hardening

- 11.1 MainLayout health indicator: warning MessageBar when fail2ban offline
- 11.2 formatDate utility + TimezoneProvider + GET /api/setup/timezone
- 11.3 Responsive sidebar: auto-collapse <640px, media query listener
- 11.4 PageFeedback (PageLoading/PageError/PageEmpty), BanTable updated
- 11.5 prefers-reduced-motion: disable sidebar transition
- 11.6 WorldMap ARIA: role/tabIndex/aria-label/onKeyDown for countries
- 11.7 Health transition logging (fail2ban_came_online/went_offline)
- 11.8 Global handlers: Fail2BanConnectionError/ProtocolError -> 502
- 11.9 379 tests pass, 82% coverage, ruff+mypy+tsc+eslint clean
- Timezone endpoint: setup_service.get_timezone, 5 new tests
This commit is contained in:
2026-03-01 15:59:06 +01:00
parent 1efa0e973b
commit 1cdc97a729
19 changed files with 649 additions and 45 deletions

View File

@@ -35,6 +35,7 @@ from app.config import Settings, get_settings
from app.db import init_db
from app.routers import auth, bans, blocklist, config, dashboard, geo, health, history, jails, server, setup
from app.tasks import blocklist_import, health_check
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
# ---------------------------------------------------------------------------
# Ensure the bundled fail2ban package is importable from fail2ban-master/
@@ -166,6 +167,56 @@ async def _unhandled_exception_handler(
)
async def _fail2ban_connection_handler(
request: Request,
exc: Fail2BanConnectionError,
) -> JSONResponse:
"""Return a ``502 Bad Gateway`` response when fail2ban is unreachable.
Args:
request: The incoming FastAPI request.
exc: The :class:`~app.utils.fail2ban_client.Fail2BanConnectionError`.
Returns:
A :class:`fastapi.responses.JSONResponse` with status 502.
"""
log.warning(
"fail2ban_connection_error",
path=request.url.path,
method=request.method,
error=str(exc),
)
return JSONResponse(
status_code=502,
content={"detail": f"Cannot reach fail2ban: {exc}"},
)
async def _fail2ban_protocol_handler(
request: Request,
exc: Fail2BanProtocolError,
) -> JSONResponse:
"""Return a ``502 Bad Gateway`` response for fail2ban protocol errors.
Args:
request: The incoming FastAPI request.
exc: The :class:`~app.utils.fail2ban_client.Fail2BanProtocolError`.
Returns:
A :class:`fastapi.responses.JSONResponse` with status 502.
"""
log.warning(
"fail2ban_protocol_error",
path=request.url.path,
method=request.method,
error=str(exc),
)
return JSONResponse(
status_code=502,
content={"detail": f"fail2ban protocol error: {exc}"},
)
# ---------------------------------------------------------------------------
# Setup-redirect middleware
# ---------------------------------------------------------------------------
@@ -269,6 +320,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_middleware(SetupRedirectMiddleware)
# --- Exception handlers ---
# Ordered from most specific to least specific. FastAPI evaluates handlers
# in the order they were registered, so fail2ban network errors get a 502
# rather than falling through to the generic 500 handler.
app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type]
app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type]
app.add_exception_handler(Exception, _unhandled_exception_handler)
# --- Routers ---

View File

@@ -45,6 +45,14 @@ class SetupResponse(BaseModel):
)
class SetupTimezoneResponse(BaseModel):
"""Response for ``GET /api/setup/timezone``."""
model_config = ConfigDict(strict=True)
timezone: str = Field(..., description="Configured IANA timezone identifier.")
class SetupStatusResponse(BaseModel):
"""Response indicating whether setup has been completed."""

View File

@@ -11,7 +11,7 @@ import structlog
from fastapi import APIRouter, HTTPException, status
from app.dependencies import DbDep
from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse
from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse, SetupTimezoneResponse
from app.services import setup_service
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -69,3 +69,23 @@ async def post_setup(body: SetupRequest, db: DbDep) -> SetupResponse:
session_duration_minutes=body.session_duration_minutes,
)
return SetupResponse()
@router.get(
"/timezone",
response_model=SetupTimezoneResponse,
summary="Return the configured IANA timezone",
)
async def get_timezone(db: DbDep) -> SetupTimezoneResponse:
"""Return the IANA timezone configured during the initial setup wizard.
The frontend uses this to convert UTC timestamps to the local time zone
chosen by the administrator.
Returns:
:class:`~app.models.setup.SetupTimezoneResponse` with ``timezone``
set to the stored IANA identifier (e.g. ``"UTC"`` or
``"Europe/Berlin"``), defaulting to ``"UTC"`` if unset.
"""
tz = await setup_service.get_timezone(db)
return SetupTimezoneResponse(timezone=tz)

View File

@@ -99,3 +99,19 @@ async def get_password_hash(db: aiosqlite.Connection) -> str | None:
The bcrypt hash string, or ``None``.
"""
return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH)
async def get_timezone(db: aiosqlite.Connection) -> str:
"""Return the configured IANA timezone string.
Falls back to ``"UTC"`` when no timezone has been stored (e.g. before
setup completes or for legacy databases).
Args:
db: Active aiosqlite connection.
Returns:
An IANA timezone identifier such as ``"Europe/Berlin"`` or ``"UTC"``.
"""
tz = await settings_repo.get_setting(db, _KEY_TIMEZONE)
return tz if tz else "UTC"

View File

@@ -104,7 +104,7 @@ def reschedule(app: FastAPI) -> None:
asyncio.ensure_future(_do_reschedule())
def _apply_schedule(app: FastAPI, config: Any) -> None: # type: ignore[override]
def _apply_schedule(app: FastAPI, config: Any) -> None:
"""Add or replace the APScheduler cron/interval job for the given config.
Args:

View File

@@ -36,8 +36,18 @@ async def _run_probe(app: Any) -> None:
scheduler via the ``kwargs`` mechanism.
"""
socket_path: str = app.state.settings.fail2ban_socket
prev_status: ServerStatus = getattr(
app.state, "server_status", ServerStatus(online=False)
)
status: ServerStatus = await health_service.probe(socket_path)
app.state.server_status = status
# Log transitions between online and offline states.
if status.online and not prev_status.online:
log.info("fail2ban_came_online", version=status.version)
elif not status.online and prev_status.online:
log.warning("fail2ban_went_offline")
log.debug(
"health_check_complete",
online=status.online,

View File

@@ -1,4 +1,4 @@
"""Tests for the setup router (POST /api/setup, GET /api/setup)."""
"""Tests for the setup router (POST /api/setup, GET /api/setup, GET /api/setup/timezone)."""
from __future__ import annotations
@@ -121,3 +121,38 @@ class TestSetupRedirectMiddleware:
)
# 401 wrong password — not a 307 redirect
assert response.status_code == 401
class TestGetTimezone:
"""GET /api/setup/timezone — return the configured IANA timezone."""
async def test_returns_utc_before_setup(self, client: AsyncClient) -> None:
"""Timezone endpoint returns 'UTC' on a fresh database (no setup yet)."""
response = await client.get("/api/setup/timezone")
assert response.status_code == 200
assert response.json() == {"timezone": "UTC"}
async def test_returns_configured_timezone(self, client: AsyncClient) -> None:
"""Timezone endpoint returns the value set during setup."""
await client.post(
"/api/setup",
json={
"master_password": "supersecret123",
"timezone": "Europe/Berlin",
},
)
response = await client.get("/api/setup/timezone")
assert response.status_code == 200
assert response.json() == {"timezone": "Europe/Berlin"}
async def test_endpoint_always_reachable_before_setup(
self, client: AsyncClient
) -> None:
"""Timezone endpoint is reachable before setup (no redirect)."""
response = await client.get(
"/api/setup/timezone",
follow_redirects=False,
)
# Should return 200, not a 307 redirect, because /api/setup paths
# are always allowed by the SetupRedirectMiddleware.
assert response.status_code == 200

View File

@@ -95,3 +95,23 @@ class TestRunSetup:
await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type]
with pytest.raises(RuntimeError, match="already been completed"):
await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type]
class TestGetTimezone:
async def test_returns_utc_on_fresh_db(self, db: aiosqlite.Connection) -> None:
"""get_timezone() returns 'UTC' before setup is run."""
assert await setup_service.get_timezone(db) == "UTC"
async def test_returns_configured_timezone(
self, db: aiosqlite.Connection
) -> None:
"""get_timezone() returns the value set during setup."""
await setup_service.run_setup(
db,
master_password="mypassword1",
database_path="bangui.db",
fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
timezone="America/New_York",
session_duration_minutes=60,
)
assert await setup_service.get_timezone(db) == "America/New_York"