feat: Stage 4 — fail2ban connection and server status
This commit is contained in:
@@ -106,29 +106,29 @@ With authentication working, this stage builds the persistent layout that every
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — fail2ban Connection & Server Status
|
||||
## Stage 4 — fail2ban Connection & Server Status ✅ DONE
|
||||
|
||||
This stage establishes the live connection to the fail2ban daemon and surfaces its health to the user. It is a prerequisite for every data-driven feature.
|
||||
|
||||
### 4.1 Implement the health service
|
||||
### 4.1 Implement the health service ✅
|
||||
|
||||
Build `backend/app/services/health_service.py`. It connects to the fail2ban socket using the wrapper from Stage 1.8, sends a `status` command, and parses the response to extract: whether the server is reachable, the fail2ban version, the number of active jails, and aggregated ban/failure counts. Expose a method that returns a structured health status object. Log connectivity changes (online → offline and vice versa) via structlog. See [Features.md § 3 (Server Status Bar)](Features.md).
|
||||
**Done.** `backend/app/services/health_service.py` — `probe(socket_path)` sends `ping`, `version`, `status`, and per-jail `status <jail>` commands via `Fail2BanClient`. Aggregates `Currently failed` and `Currently banned` across all jails. Returns `ServerStatus(online=True/False)`. `Fail2BanConnectionError` and `Fail2BanProtocolError` mapped to `online=False`. `_ok()` helper extracts payload from `(return_code, data)` tuples; `_to_dict()` normalises fail2ban's list-of-pairs format.
|
||||
|
||||
### 4.2 Implement the health-check background task
|
||||
### 4.2 Implement the health-check background task ✅
|
||||
|
||||
Create `backend/app/tasks/health_check.py` — an APScheduler job that runs the health service probe every 30 seconds and caches the result in memory (e.g. on `app.state`). This ensures the dashboard endpoint can return fresh status without blocking on a socket call. See [Architekture.md § 2.2 (Tasks)](Architekture.md).
|
||||
**Done.** `backend/app/tasks/health_check.py` — `register(app)` adds an APScheduler `interval` job that fires every 30 seconds (and immediately on startup via `next_run_time`). Result stored on `app.state.server_status`. `app.state.server_status` initialised to `ServerStatus(online=False)` as a safe placeholder. Wired into `main.py` lifespan after `scheduler.start()`.
|
||||
|
||||
### 4.3 Implement the dashboard status endpoint
|
||||
### 4.3 Implement the dashboard status endpoint ✅
|
||||
|
||||
Create `backend/app/routers/dashboard.py` with a `GET /api/dashboard/status` endpoint that returns the cached server status (online/offline, version, jail count, total bans, total failures). Define response models in `backend/app/models/server.py`. This endpoint is lightweight — it reads from the in-memory cache populated by the health-check task.
|
||||
**Done.** `backend/app/routers/dashboard.py` — `GET /api/dashboard/status` reads `app.state.server_status` (falls back to `ServerStatus(online=False)` when not yet set). Response model `ServerStatusResponse` from `backend/app/models/server.py` (pre-existing). Requires `AuthDep`. Registered in `create_app()`.
|
||||
|
||||
### 4.4 Build the server status bar component (frontend)
|
||||
### 4.4 Build the server status bar component (frontend) ✅
|
||||
|
||||
Create `frontend/src/components/ServerStatusBar.tsx`. This persistent bar appears at the top of the dashboard (and optionally on other pages). It displays the fail2ban connection status (green badge for online, red for offline), the server version, active jail count, and total bans/failures. Use Fluent UI `Badge` and `Text` components. Poll `GET /api/dashboard/status` at a reasonable interval or on page focus. Create `frontend/src/api/dashboard.ts`, `frontend/src/types/server.ts`, and a `useServerStatus` hook.
|
||||
**Done.** `frontend/src/types/server.ts` — `ServerStatus` and `ServerStatusResponse` interfaces. `frontend/src/api/dashboard.ts` — `fetchServerStatus()`. `frontend/src/hooks/useServerStatus.ts` — `useServerStatus()` hook polling every 30 s and on window focus. `frontend/src/components/ServerStatusBar.tsx` — Fluent UI v9 `Badge`, `Text`, `Spinner`, `Tooltip`; green/red badge for online/offline; version, jail count, bans, failures stats; refresh button. `DashboardPage.tsx` updated to render `<ServerStatusBar />` at the top.
|
||||
|
||||
### 4.5 Write tests for health service and dashboard
|
||||
### 4.5 Write tests for health service and dashboard ✅
|
||||
|
||||
Test that the health service correctly parses a mock fail2ban status response, handles socket errors gracefully, and that the dashboard endpoint returns the expected shape. Mock the fail2ban socket — tests must never touch a real daemon.
|
||||
**Done.** 104 total tests pass (+19 new). `backend/tests/test_services/test_health_service.py` — 12 tests covering: online probe (version, jail count, ban/failure aggregation, empty jail list), connection error → offline, protocol error → offline, bad/error ping → offline, per-jail parse error tolerated, version failure tolerated. `backend/tests/test_routers/test_dashboard.py` — 6 tests covering: 200 when authenticated, 401 when unauthenticated, response shape, cached values returned, offline status, safe default when cache absent. fail2ban socket mocked via `unittest.mock.patch`. ruff 0 errors, mypy --strict 0 errors, tsc --noEmit 0 errors.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import auth, health, setup
|
||||
from app.routers import auth, dashboard, health, setup
|
||||
from app.tasks import health_check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
||||
@@ -114,6 +115,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
scheduler.start()
|
||||
app.state.scheduler = scheduler
|
||||
|
||||
# --- Health-check background probe ---
|
||||
health_check.register(app)
|
||||
|
||||
log.info("bangui_started")
|
||||
|
||||
try:
|
||||
@@ -268,5 +272,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.include_router(health.router)
|
||||
app.include_router(setup.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
|
||||
return app
|
||||
|
||||
46
backend/app/routers/dashboard.py
Normal file
46
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Dashboard router.
|
||||
|
||||
Provides the ``GET /api/dashboard/status`` endpoint that returns the cached
|
||||
fail2ban server health snapshot. The snapshot is maintained by the
|
||||
background health-check task and refreshed every 30 seconds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.server import ServerStatus, ServerStatusResponse
|
||||
|
||||
router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status",
|
||||
response_model=ServerStatusResponse,
|
||||
summary="Return the cached fail2ban server status",
|
||||
)
|
||||
async def get_server_status(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> ServerStatusResponse:
|
||||
"""Return the most recent fail2ban health snapshot.
|
||||
|
||||
The snapshot is populated by a background task that runs every 30 seconds.
|
||||
If the task has not yet executed a placeholder ``online=False`` status is
|
||||
returned so the response is always well-formed.
|
||||
|
||||
Args:
|
||||
request: The incoming request (used to access ``app.state``).
|
||||
_auth: Validated session — enforces authentication on this endpoint.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.server.ServerStatusResponse` containing the
|
||||
current health snapshot.
|
||||
"""
|
||||
cached: ServerStatus = getattr(
|
||||
request.app.state,
|
||||
"server_status",
|
||||
ServerStatus(online=False),
|
||||
)
|
||||
return ServerStatusResponse(status=cached)
|
||||
171
backend/app/services/health_service.py
Normal file
171
backend/app/services/health_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Health service.
|
||||
|
||||
Probes the fail2ban socket to determine whether the daemon is reachable and
|
||||
collects aggregated server statistics (version, jail count, ban counts).
|
||||
|
||||
The probe is intentionally lightweight — it is meant to be called every 30
|
||||
seconds by the background health-check task, not on every HTTP request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.server import ServerStatus
|
||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError, Fail2BanProtocolError
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOCKET_TIMEOUT: float = 5.0
|
||||
|
||||
|
||||
def _ok(response: Any) -> Any:
|
||||
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
fail2ban wraps every response in a ``(0, data)`` success tuple or
|
||||
a ``(1, exception)`` error tuple. This helper returns ``data`` for
|
||||
successful responses or raises :class:`ValueError` for error responses.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the response indicates an error (return code ≠ 0).
|
||||
"""
|
||||
try:
|
||||
code, data = response
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||||
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _to_dict(pairs: Any) -> dict[str, Any]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict.
|
||||
|
||||
fail2ban returns structured data as lists of 2-tuples rather than dicts.
|
||||
This helper converts them safely, ignoring non-pair items.
|
||||
|
||||
Args:
|
||||
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
|
||||
|
||||
Returns:
|
||||
A :class:`dict` with the keys and values from *pairs*.
|
||||
"""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def probe(socket_path: str, timeout: float = _SOCKET_TIMEOUT) -> ServerStatus:
|
||||
"""Probe the fail2ban daemon and return a :class:`~app.models.server.ServerStatus`.
|
||||
|
||||
Sends ``ping``, ``version``, ``status``, and per-jail ``status <jail>``
|
||||
commands. Any socket or protocol error is caught and results in an
|
||||
``online=False`` status so the dashboard can always return a safe default.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
timeout: Per-command socket timeout in seconds.
|
||||
|
||||
Returns:
|
||||
A :class:`~app.models.server.ServerStatus` snapshot. ``online`` is
|
||||
``True`` when the daemon is reachable, ``False`` otherwise.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=timeout)
|
||||
|
||||
try:
|
||||
# ------------------------------------------------------------------ #
|
||||
# 1. Connectivity check #
|
||||
# ------------------------------------------------------------------ #
|
||||
ping_data = _ok(await client.send(["ping"]))
|
||||
if ping_data != "pong":
|
||||
log.warning("fail2ban_unexpected_ping_response", response=ping_data)
|
||||
return ServerStatus(online=False)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 2. Version #
|
||||
# ------------------------------------------------------------------ #
|
||||
try:
|
||||
version: str | None = str(_ok(await client.send(["version"])))
|
||||
except (ValueError, TypeError):
|
||||
version = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3. Global status — jail count and names #
|
||||
# ------------------------------------------------------------------ #
|
||||
status_data = _to_dict(_ok(await client.send(["status"])))
|
||||
active_jails: int = int(status_data.get("Number of jail", 0) or 0)
|
||||
jail_list_raw: str = str(status_data.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
if jail_list_raw
|
||||
else []
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 4. Per-jail aggregation #
|
||||
# ------------------------------------------------------------------ #
|
||||
total_bans: int = 0
|
||||
total_failures: int = 0
|
||||
|
||||
for jail_name in jail_names:
|
||||
try:
|
||||
jail_resp = _to_dict(_ok(await client.send(["status", jail_name])))
|
||||
filter_stats = _to_dict(jail_resp.get("Filter") or [])
|
||||
action_stats = _to_dict(jail_resp.get("Actions") or [])
|
||||
total_failures += int(filter_stats.get("Currently failed", 0) or 0)
|
||||
total_bans += int(action_stats.get("Currently banned", 0) or 0)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
log.warning(
|
||||
"fail2ban_jail_status_parse_error",
|
||||
jail=jail_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.debug(
|
||||
"fail2ban_probe_ok",
|
||||
version=version,
|
||||
active_jails=active_jails,
|
||||
total_bans=total_bans,
|
||||
total_failures=total_failures,
|
||||
)
|
||||
|
||||
return ServerStatus(
|
||||
online=True,
|
||||
version=version,
|
||||
active_jails=active_jails,
|
||||
total_bans=total_bans,
|
||||
total_failures=total_failures,
|
||||
)
|
||||
|
||||
except (Fail2BanConnectionError, Fail2BanProtocolError) as exc:
|
||||
log.warning("fail2ban_probe_failed", error=str(exc))
|
||||
return ServerStatus(online=False)
|
||||
except ValueError as exc:
|
||||
log.error("fail2ban_probe_parse_error", error=str(exc))
|
||||
return ServerStatus(online=False)
|
||||
79
backend/app/tasks/health_check.py
Normal file
79
backend/app/tasks/health_check.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Health-check background task.
|
||||
|
||||
Registers an APScheduler job that probes the fail2ban socket every 30 seconds
|
||||
and stores the result on ``app.state.server_status``. The dashboard endpoint
|
||||
reads from this cache, keeping HTTP responses fast and the daemon connection
|
||||
decoupled from user-facing requests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.server import ServerStatus
|
||||
from app.services import health_service
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from fastapi import FastAPI
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
#: How often the probe fires (seconds).
|
||||
HEALTH_CHECK_INTERVAL: int = 30
|
||||
|
||||
|
||||
async def _run_probe(app: Any) -> None:
|
||||
"""Probe fail2ban and cache the result on *app.state*.
|
||||
|
||||
This is the APScheduler job callback. It reads ``fail2ban_socket`` from
|
||||
``app.state.settings``, runs the health probe, and writes the result to
|
||||
``app.state.server_status``.
|
||||
|
||||
Args:
|
||||
app: The :class:`fastapi.FastAPI` application instance passed by the
|
||||
scheduler via the ``kwargs`` mechanism.
|
||||
"""
|
||||
socket_path: str = app.state.settings.fail2ban_socket
|
||||
status: ServerStatus = await health_service.probe(socket_path)
|
||||
app.state.server_status = status
|
||||
log.debug(
|
||||
"health_check_complete",
|
||||
online=status.online,
|
||||
version=status.version,
|
||||
active_jails=status.active_jails,
|
||||
)
|
||||
|
||||
|
||||
def register(app: FastAPI) -> None:
|
||||
"""Add the health-check job to the application scheduler.
|
||||
|
||||
Must be called after the scheduler has been started (i.e., inside the
|
||||
lifespan handler, after ``scheduler.start()``).
|
||||
|
||||
Args:
|
||||
app: The :class:`fastapi.FastAPI` application instance whose
|
||||
``app.state.scheduler`` will receive the job.
|
||||
"""
|
||||
# Initialise the cache with an offline placeholder so the dashboard
|
||||
# endpoint is always able to return a valid response even before the
|
||||
# first probe fires.
|
||||
app.state.server_status = ServerStatus(online=False)
|
||||
|
||||
app.state.scheduler.add_job(
|
||||
_run_probe,
|
||||
trigger="interval",
|
||||
seconds=HEALTH_CHECK_INTERVAL,
|
||||
kwargs={"app": app},
|
||||
id="health_check",
|
||||
replace_existing=True,
|
||||
# Fire immediately on startup too, so the UI isn't dark for 30 s.
|
||||
next_run_time=__import__("datetime").datetime.now(
|
||||
tz=__import__("datetime").timezone.utc
|
||||
),
|
||||
)
|
||||
log.info(
|
||||
"health_check_scheduled",
|
||||
interval_seconds=HEALTH_CHECK_INTERVAL,
|
||||
)
|
||||
@@ -43,8 +43,8 @@ ignore = ["B008"] # FastAPI uses function calls in default arguments (Depends)
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# sys.path manipulation before stdlib imports is intentional in test helpers
|
||||
# pytest evaluates fixture type annotations at runtime, so TC002/TC003 are false-positives
|
||||
"tests/**" = ["E402", "TC002", "TC003"]
|
||||
# pytest evaluates fixture type annotations at runtime, so TC001/TC002/TC003 are false-positives
|
||||
"tests/**" = ["E402", "TC001", "TC002", "TC003"]
|
||||
"app/routers/**" = ["TC001"] # FastAPI evaluates Depends() type aliases at runtime via get_type_hints()
|
||||
|
||||
[tool.ruff.format]
|
||||
|
||||
194
backend/tests/test_routers/test_dashboard.py
Normal file
194
backend/tests/test_routers/test_dashboard.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Tests for the dashboard router (GET /api/dashboard/status)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.server import ServerStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SETUP_PAYLOAD = {
|
||||
"master_password": "testpassword1",
|
||||
"database_path": "bangui.db",
|
||||
"fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
|
||||
"timezone": "UTC",
|
||||
"session_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Provide an authenticated ``AsyncClient`` with a pre-seeded server status.
|
||||
|
||||
Unlike the shared ``client`` fixture this one also exposes access to
|
||||
``app.state`` via the app instance so we can seed the status cache.
|
||||
"""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "dashboard_test.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
session_secret="test-dashboard-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
|
||||
# Pre-seed a server status so the endpoint has something to return.
|
||||
app.state.server_status = ServerStatus(
|
||||
online=True,
|
||||
version="1.0.2",
|
||||
active_jails=2,
|
||||
total_bans=10,
|
||||
total_failures=5,
|
||||
)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
# Complete setup so the middleware doesn't redirect.
|
||||
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
|
||||
# Login to get a session cookie.
|
||||
login_resp = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def offline_dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc]
|
||||
"""Like ``dashboard_client`` but with an offline server status."""
|
||||
settings = Settings(
|
||||
database_path=str(tmp_path / "dashboard_offline_test.db"),
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
session_secret="test-dashboard-offline-secret",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
app = create_app(settings=settings)
|
||||
|
||||
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||
db.row_factory = aiosqlite.Row
|
||||
await init_db(db)
|
||||
app.state.db = db
|
||||
|
||||
app.state.server_status = ServerStatus(online=False)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
assert resp.status_code == 201
|
||||
|
||||
login_resp = await ac.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
yield ac
|
||||
|
||||
await db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDashboardStatus:
|
||||
"""GET /api/dashboard/status."""
|
||||
|
||||
async def test_returns_200_when_authenticated(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Authenticated request returns HTTP 200."""
|
||||
response = await dashboard_client.get("/api/dashboard/status")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_returns_401_when_unauthenticated(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Unauthenticated request returns HTTP 401."""
|
||||
# Complete setup so the middleware allows the request through.
|
||||
await client.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
response = await client.get("/api/dashboard/status")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_response_shape_when_online(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Response contains the expected ``status`` object shape."""
|
||||
response = await dashboard_client.get("/api/dashboard/status")
|
||||
body = response.json()
|
||||
|
||||
assert "status" in body
|
||||
status = body["status"]
|
||||
assert "online" in status
|
||||
assert "version" in status
|
||||
assert "active_jails" in status
|
||||
assert "total_bans" in status
|
||||
assert "total_failures" in status
|
||||
|
||||
async def test_cached_values_returned_when_online(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Endpoint returns the exact values from ``app.state.server_status``."""
|
||||
response = await dashboard_client.get("/api/dashboard/status")
|
||||
status = response.json()["status"]
|
||||
|
||||
assert status["online"] is True
|
||||
assert status["version"] == "1.0.2"
|
||||
assert status["active_jails"] == 2
|
||||
assert status["total_bans"] == 10
|
||||
assert status["total_failures"] == 5
|
||||
|
||||
async def test_offline_status_returned_correctly(
|
||||
self, offline_dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""Endpoint returns online=False when the cache holds an offline snapshot."""
|
||||
response = await offline_dashboard_client.get("/api/dashboard/status")
|
||||
assert response.status_code == 200
|
||||
status = response.json()["status"]
|
||||
|
||||
assert status["online"] is False
|
||||
assert status["version"] is None
|
||||
assert status["active_jails"] == 0
|
||||
assert status["total_bans"] == 0
|
||||
assert status["total_failures"] == 0
|
||||
|
||||
async def test_returns_offline_when_state_not_initialised(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
"""Endpoint returns online=False as a safe default if the cache is absent."""
|
||||
# Setup + login so the endpoint is reachable.
|
||||
await client.post("/api/setup", json=_SETUP_PAYLOAD)
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"password": _SETUP_PAYLOAD["master_password"]},
|
||||
)
|
||||
# server_status is not set on app.state in the shared `client` fixture.
|
||||
response = await client.get("/api/dashboard/status")
|
||||
assert response.status_code == 200
|
||||
status = response.json()["status"]
|
||||
assert status["online"] is False
|
||||
263
backend/tests/test_services/test_health_service.py
Normal file
263
backend/tests/test_services/test_health_service.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Tests for health_service.probe()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.server import ServerStatus
|
||||
from app.services import health_service
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SOCKET = "/fake/fail2ban.sock"
|
||||
|
||||
|
||||
def _make_send(responses: dict[str, Any]) -> AsyncMock:
|
||||
"""Build an ``AsyncMock`` for ``Fail2BanClient.send`` keyed by command[0].
|
||||
|
||||
For the ``["status", jail_name]`` command the key is
|
||||
``"status:<jail_name>"``.
|
||||
"""
|
||||
|
||||
async def _side_effect(command: list[str]) -> Any:
|
||||
key = f"status:{command[1]}" if len(command) >= 2 and command[0] == "status" else command[0]
|
||||
if key not in responses:
|
||||
raise KeyError(f"Unexpected command key {key!r} in mock")
|
||||
return responses[key]
|
||||
|
||||
mock = AsyncMock(side_effect=_side_effect)
|
||||
return mock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProbeOnline:
|
||||
"""Verify probe() correctly parses a healthy fail2ban response."""
|
||||
|
||||
async def test_online_flag_is_true(self) -> None:
|
||||
"""status.online is True when ping succeeds."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": (0, "1.0.2"),
|
||||
"status": (0, [("Number of jail", 0), ("Jail list", "")]),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result: ServerStatus = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.online is True
|
||||
|
||||
async def test_version_parsed(self) -> None:
|
||||
"""status.version contains the version string returned by fail2ban."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": (0, "1.1.0"),
|
||||
"status": (0, [("Number of jail", 0), ("Jail list", "")]),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.version == "1.1.0"
|
||||
|
||||
async def test_active_jails_count(self) -> None:
|
||||
"""status.active_jails reflects the jail count from the status command."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": (0, "1.0.2"),
|
||||
"status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]),
|
||||
"status:sshd": (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", 3), ("Total failed", 100)]),
|
||||
("Actions", [("Currently banned", 1), ("Total banned", 50)]),
|
||||
],
|
||||
),
|
||||
"status:nginx": (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", 2), ("Total failed", 50)]),
|
||||
("Actions", [("Currently banned", 0), ("Total banned", 10)]),
|
||||
],
|
||||
),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.active_jails == 2
|
||||
|
||||
async def test_total_bans_aggregated(self) -> None:
|
||||
"""status.total_bans sums 'Currently banned' across all jails."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": (0, "1.0.2"),
|
||||
"status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]),
|
||||
"status:sshd": (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", 3), ("Total failed", 100)]),
|
||||
("Actions", [("Currently banned", 4), ("Total banned", 50)]),
|
||||
],
|
||||
),
|
||||
"status:nginx": (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", 1), ("Total failed", 20)]),
|
||||
("Actions", [("Currently banned", 2), ("Total banned", 15)]),
|
||||
],
|
||||
),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.total_bans == 6 # 4 + 2
|
||||
|
||||
async def test_total_failures_aggregated(self) -> None:
|
||||
"""status.total_failures sums 'Currently failed' across all jails."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": (0, "1.0.2"),
|
||||
"status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]),
|
||||
"status:sshd": (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", 3), ("Total failed", 100)]),
|
||||
("Actions", [("Currently banned", 1), ("Total banned", 50)]),
|
||||
],
|
||||
),
|
||||
"status:nginx": (
|
||||
0,
|
||||
[
|
||||
("Filter", [("Currently failed", 2), ("Total failed", 20)]),
|
||||
("Actions", [("Currently banned", 0), ("Total banned", 10)]),
|
||||
],
|
||||
),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.total_failures == 5 # 3 + 2
|
||||
|
||||
async def test_empty_jail_list(self) -> None:
|
||||
"""Probe succeeds with zero jails — no per-jail queries are made."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": (0, "1.0.2"),
|
||||
"status": (0, [("Number of jail", 0), ("Jail list", "")]),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.online is True
|
||||
assert result.active_jails == 0
|
||||
assert result.total_bans == 0
|
||||
assert result.total_failures == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProbeOffline:
|
||||
"""Verify probe() returns online=False when the daemon is unreachable."""
|
||||
|
||||
async def test_connection_error_returns_offline(self) -> None:
|
||||
"""Fail2BanConnectionError → online=False."""
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = AsyncMock(
|
||||
side_effect=Fail2BanConnectionError("socket not found", _SOCKET)
|
||||
)
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.online is False
|
||||
assert result.version is None
|
||||
|
||||
async def test_protocol_error_returns_offline(self) -> None:
|
||||
"""Fail2BanProtocolError → online=False."""
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = AsyncMock(
|
||||
side_effect=Fail2BanProtocolError("bad pickle")
|
||||
)
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.online is False
|
||||
|
||||
async def test_bad_ping_response_returns_offline(self) -> None:
|
||||
"""An unexpected ping response → online=False (defensive guard)."""
|
||||
send = _make_send({"ping": (0, "NOTPONG")})
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.online is False
|
||||
|
||||
async def test_error_code_in_ping_returns_offline(self) -> None:
|
||||
"""An error return code in the ping response → online=False."""
|
||||
send = _make_send({"ping": (1, "ERROR")})
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.online is False
|
||||
|
||||
async def test_per_jail_error_is_tolerated(self) -> None:
|
||||
"""A parse error on an individual jail's status does not break the probe."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": (0, "1.0.2"),
|
||||
"status": (0, [("Number of jail", 1), ("Jail list", "sshd")]),
|
||||
# Return garbage to trigger parse tolerance.
|
||||
"status:sshd": (0, "INVALID"),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
# The service should still be online even if per-jail parsing fails.
|
||||
assert result.online is True
|
||||
assert result.total_bans == 0
|
||||
assert result.total_failures == 0
|
||||
|
||||
@pytest.mark.parametrize("version_return", [(1, "ERROR"), (0, None)])
|
||||
async def test_version_failure_is_tolerated(self, version_return: tuple[int, Any]) -> None:
|
||||
"""A failed or null version response does not prevent a successful probe."""
|
||||
send = _make_send(
|
||||
{
|
||||
"ping": (0, "pong"),
|
||||
"version": version_return,
|
||||
"status": (0, [("Number of jail", 0), ("Jail list", "")]),
|
||||
}
|
||||
)
|
||||
with patch("app.services.health_service.Fail2BanClient") as mock_client:
|
||||
mock_client.return_value.send = send
|
||||
result = await health_service.probe(_SOCKET)
|
||||
|
||||
assert result.online is True
|
||||
20
frontend/src/api/dashboard.ts
Normal file
20
frontend/src/api/dashboard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Dashboard API module.
|
||||
*
|
||||
* Wraps the `GET /api/dashboard/status` endpoint.
|
||||
*/
|
||||
|
||||
import { get } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type { ServerStatusResponse } from "../types/server";
|
||||
|
||||
/**
|
||||
* Fetch the cached fail2ban server status from the backend.
|
||||
*
|
||||
* @returns The server status response containing ``online``, ``version``,
|
||||
* ``active_jails``, ``total_bans``, and ``total_failures``.
|
||||
* @throws {ApiError} When the server returns a non-2xx status.
|
||||
*/
|
||||
export async function fetchServerStatus(): Promise<ServerStatusResponse> {
|
||||
return get<ServerStatusResponse>(ENDPOINTS.dashboardStatus);
|
||||
}
|
||||
179
frontend/src/components/ServerStatusBar.tsx
Normal file
179
frontend/src/components/ServerStatusBar.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* `ServerStatusBar` component.
|
||||
*
|
||||
* Displays a persistent bar at the top of the dashboard showing the
|
||||
* fail2ban server health snapshot: connectivity status, version, active
|
||||
* jail count, and aggregated ban/failure totals.
|
||||
*
|
||||
* Polls `GET /api/dashboard/status` every 30 seconds and on window focus
|
||||
* via the {@link useServerStatus} hook.
|
||||
*/
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
makeStyles,
|
||||
Spinner,
|
||||
Text,
|
||||
tokens,
|
||||
Tooltip,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowClockwiseRegular, ShieldRegular } from "@fluentui/react-icons";
|
||||
import { useServerStatus } from "../hooks/useServerStatus";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
bar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalL,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
borderTopWidth: "1px",
|
||||
borderTopStyle: "solid",
|
||||
borderTopColor: tokens.colorNeutralStroke2,
|
||||
borderRightWidth: "1px",
|
||||
borderRightStyle: "solid",
|
||||
borderRightColor: tokens.colorNeutralStroke2,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
borderLeftWidth: "1px",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftColor: tokens.colorNeutralStroke2,
|
||||
marginBottom: tokens.spacingVerticalL,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
statusGroup: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
statGroup: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
},
|
||||
statValue: {
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
fontWeight: 600,
|
||||
},
|
||||
spacer: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
errorText: {
|
||||
color: tokens.colorPaletteRedForeground1,
|
||||
fontSize: "12px",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Persistent bar displaying fail2ban server health.
|
||||
*
|
||||
* Render this at the top of the dashboard page (and any page that should
|
||||
* show live server status).
|
||||
*/
|
||||
export function ServerStatusBar(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { status, loading, error, refresh } = useServerStatus();
|
||||
|
||||
return (
|
||||
<div className={styles.bar} role="status" aria-label="fail2ban server status">
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Online / Offline badge */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<div className={styles.statusGroup}>
|
||||
<ShieldRegular fontSize={16} />
|
||||
{loading && !status ? (
|
||||
<Spinner size="extra-tiny" label="Checking…" labelPosition="after" />
|
||||
) : (
|
||||
<Badge
|
||||
appearance="filled"
|
||||
color={status?.online ? "success" : "danger"}
|
||||
aria-label={status?.online ? "fail2ban online" : "fail2ban offline"}
|
||||
>
|
||||
{status?.online ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Version */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{status?.version != null && (
|
||||
<Tooltip content="fail2ban version" relationship="description">
|
||||
<Text size={200} className={styles.statValue}>
|
||||
v{status.version}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Stats (only when online) */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{status?.online === true && (
|
||||
<>
|
||||
<Tooltip content="Active jails" relationship="description">
|
||||
<div className={styles.statGroup}>
|
||||
<Text size={200}>Jails:</Text>
|
||||
<Text size={200} className={styles.statValue}>
|
||||
{status.active_jails}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Currently banned IPs" relationship="description">
|
||||
<div className={styles.statGroup}>
|
||||
<Text size={200}>Bans:</Text>
|
||||
<Text size={200} className={styles.statValue}>
|
||||
{status.total_bans}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="Currently failing IPs" relationship="description">
|
||||
<div className={styles.statGroup}>
|
||||
<Text size={200}>Failures:</Text>
|
||||
<Text size={200} className={styles.statValue}>
|
||||
{status.total_failures}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Error message */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{error != null && (
|
||||
<Text className={styles.errorText} aria-live="polite">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className={styles.spacer} />
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Refresh button */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<Tooltip content="Refresh server status" relationship="label">
|
||||
<Button
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
icon={loading ? <Spinner size="extra-tiny" /> : <ArrowClockwiseRegular />}
|
||||
onClick={refresh}
|
||||
aria-label="Refresh server status"
|
||||
disabled={loading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/hooks/useServerStatus.ts
Normal file
81
frontend/src/hooks/useServerStatus.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* `useServerStatus` hook.
|
||||
*
|
||||
* Fetches and periodically refreshes the fail2ban server health snapshot
|
||||
* from `GET /api/dashboard/status`. Also refetches on window focus so the
|
||||
* status is always fresh when the user returns to the tab.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchServerStatus } from "../api/dashboard";
|
||||
import type { ServerStatus } from "../types/server";
|
||||
|
||||
/** How often to poll the status endpoint (milliseconds). */
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/** Return value of the {@link useServerStatus} hook. */
|
||||
export interface UseServerStatusResult {
|
||||
/** The most recent server status snapshot, or `null` before the first fetch. */
|
||||
status: ServerStatus | null;
|
||||
/** Whether a fetch is currently in flight. */
|
||||
loading: boolean;
|
||||
/** Error message string when the last fetch failed, otherwise `null`. */
|
||||
error: string | null;
|
||||
/** Manually trigger a refresh immediately. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll `GET /api/dashboard/status` every 30 seconds and on window focus.
|
||||
*
|
||||
* @returns Current status, loading state, error, and a `refresh` callback.
|
||||
*/
|
||||
export function useServerStatus(): UseServerStatusResult {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use a ref so the fetch function identity is stable.
|
||||
const fetchRef = useRef<() => void>(() => undefined);
|
||||
|
||||
const doFetch = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchServerStatus();
|
||||
setStatus(data.status);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch server status");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
fetchRef.current = doFetch;
|
||||
|
||||
// Initial fetch + polling interval.
|
||||
useEffect(() => {
|
||||
void doFetch();
|
||||
|
||||
const id = setInterval(() => {
|
||||
void fetchRef.current();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [doFetch]);
|
||||
|
||||
// Refetch on window focus.
|
||||
useEffect(() => {
|
||||
const onFocus = (): void => {
|
||||
void fetchRef.current();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback((): void => {
|
||||
void doFetch();
|
||||
}, [doFetch]);
|
||||
|
||||
return { status, loading, error, refresh };
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
/**
|
||||
* Dashboard placeholder page.
|
||||
* Dashboard page.
|
||||
*
|
||||
* Full implementation is delivered in Stage 5.
|
||||
* Shows the fail2ban server status bar at the top.
|
||||
* Full ban-list implementation is delivered in Stage 5.
|
||||
*/
|
||||
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { ServerStatusBar } from "../components/ServerStatusBar";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalM,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Temporary dashboard placeholder rendered until Stage 5 is complete.
|
||||
* Dashboard page — renders the server status bar and a Stage 5 placeholder.
|
||||
*/
|
||||
export function DashboardPage(): JSX.Element {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<ServerStatusBar />
|
||||
<Text as="h1" size={700} weight="semibold">
|
||||
Dashboard
|
||||
</Text>
|
||||
|
||||
24
frontend/src/types/server.ts
Normal file
24
frontend/src/types/server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* TypeScript interfaces that mirror the backend's server status Pydantic models.
|
||||
*
|
||||
* `backend/app/models/server.py`
|
||||
*/
|
||||
|
||||
/** Cached fail2ban server health snapshot. */
|
||||
export interface ServerStatus {
|
||||
/** Whether fail2ban is reachable via its socket. */
|
||||
online: boolean;
|
||||
/** fail2ban version string, or null when offline. */
|
||||
version: string | null;
|
||||
/** Number of currently active jails. */
|
||||
active_jails: number;
|
||||
/** Aggregated current ban count across all jails. */
|
||||
total_bans: number;
|
||||
/** Aggregated current failure count across all jails. */
|
||||
total_failures: number;
|
||||
}
|
||||
|
||||
/** Response shape for ``GET /api/dashboard/status``. */
|
||||
export interface ServerStatusResponse {
|
||||
status: ServerStatus;
|
||||
}
|
||||
Reference in New Issue
Block a user