Add fail2ban log viewer and service health to Config page

Task 2: adds a new Log tab to the Configuration page.

Backend:
- New Pydantic models: Fail2BanLogResponse, ServiceStatusResponse
  (backend/app/models/config.py)
- New service methods in config_service.py:
    read_fail2ban_log() — queries socket for log target/level, validates the
    resolved path against a safe-prefix allowlist (/var/log) to prevent
    path traversal, then reads the tail of the file via the existing
    _read_tail_lines() helper; optional substring filter applied server-side.
    get_service_status() — delegates to health_service.probe() and appends
    log level/target from the socket.
- New endpoints in routers/config.py:
    GET /api/config/fail2ban-log?lines=200&filter=...
    GET /api/config/service-status
  Both require authentication; log endpoint returns 400 for non-file log
  targets or path-traversal attempts, 502 when fail2ban is unreachable.

Frontend:
- New LogTab.tsx component:
    Service Health panel (Running/Offline badge, version, jail count, bans,
    failures, log level/target, offline warning banner).
    Log viewer with color-coded lines (error=red, warning=yellow,
    debug=grey), toolbar (filter input + debounce, lines selector, manual
    refresh, auto-refresh with interval selector), truncation notice, and
    auto-scroll to bottom on data updates.
  fetchData uses Promise.allSettled so a log-read failure never hides the
  service-health panel.
- Types: Fail2BanLogResponse, ServiceStatusResponse (types/config.ts)
- API functions: fetchFail2BanLog, fetchServiceStatus (api/config.ts)
- Endpoint constants (api/endpoints.ts)
- ConfigPage.tsx: Log tab added after existing tabs

Tests:
- Backend service tests: TestReadFail2BanLog (6), TestGetServiceStatus (2)
- Backend router tests: TestGetFail2BanLog (8), TestGetServiceStatus (3)
- Frontend: LogTab.test.tsx (8 tests)

Docs:
- Features.md: Log section added under Configuration View
- Architekture.md: config.py router and config_service.py descriptions updated
- Tasks.md: Task 2 marked done
This commit is contained in:
2026-03-14 12:54:03 +01:00
parent 5e1b8134d9
commit ab11ece001
15 changed files with 1527 additions and 4 deletions

View File

@@ -152,7 +152,7 @@ The HTTP interface layer. Each router maps URL paths to handler functions. Route
| `dashboard.py` | `/api/dashboard` | Server status bar data, recent bans for the dashboard |
| `jails.py` | `/api/jails` | List jails, jail detail, start/stop/reload/idle controls |
| `bans.py` | `/api/bans` | Ban an IP, unban an IP, unban all, list currently banned IPs |
| `config.py` | `/api/config` | Read and write fail2ban jail/filter/server configuration via the socket |
| `config.py` | `/api/config` | Read and write fail2ban jail/filter/server configuration via the socket; also serves the fail2ban log tail and service status for the Log tab |
| `file_config.py` | `/api/config` | Read and write fail2ban config files on disk (jail.d/, filter.d/, action.d/) — list, get, and overwrite raw file contents, toggle jail enabled/disabled |
| `history.py` | `/api/history` | Query historical bans, per-IP timeline |
| `blocklist.py` | `/api/blocklists` | CRUD blocklist sources, trigger import, view import logs |
@@ -169,7 +169,7 @@ The business logic layer. Services orchestrate operations, enforce rules, and co
| `setup_service.py` | Validates setup input, persists initial configuration, ensures setup runs only once |
| `jail_service.py` | Retrieves jail list and details from fail2ban, aggregates metrics (banned count, failure count), sends start/stop/reload/idle commands |
| `ban_service.py` | Executes ban and unban commands via the fail2ban socket, queries the currently banned IP list, validates IPs before banning |
| `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload |
| `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload; reads the fail2ban log file tail and queries service status for the Log tab |
| `file_config_service.py` | Reads and writes raw fail2ban config files on disk (jail.d/, filter.d/, action.d/); lists files, reads content, overwrites files, toggles enabled/disabled |
| `config_file_service.py` | Parses jail.conf / jail.local / jail.d/* to discover inactive jails; writes .local overrides to activate or deactivate jails; triggers fail2ban reload |
| `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text |

View File

@@ -220,6 +220,27 @@ A page to inspect and modify the fail2ban configuration without leaving the web
- Countries with zero bans remain transparent (no fill).
- Changes take effect immediately on the World Map view without requiring a page reload.
### Log
- A dedicated **Log** tab on the Configuration page shows fail2ban service health and a live log viewer in one place.
- **Service Health panel** (always visible):
- Online/offline **badge** (Running / Offline).
- When online: version, active jail count, currently banned IPs, and currently failed attempts as stat cards.
- Log level and log target displayed as meta labels.
- Warning banner when fail2ban is offline, prompting the user to check the server and socket configuration.
- **Log Viewer** (shown when fail2ban logs to a file):
- Displays the tail of the fail2ban log file in a scrollable monospace container.
- Log lines are **color-coded by severity**: errors and critical messages in red, warnings in yellow, debug lines in grey, and informational lines in the default color.
- Toolbar controls:
- **Filter** — substring input with 300 ms debounce; only lines containing the filter text are shown.
- **Lines** — selector for how many tail lines to fetch (100 / 200 / 500 / 1000).
- **Refresh** button for an on-demand reload.
- **Auto-refresh** toggle with interval selector (5 s / 10 s / 30 s) for live monitoring.
- Truncation notice when the total log file line count exceeds the requested tail limit.
- Container automatically scrolls to the bottom after each data update.
- When fail2ban is configured to log to a non-file target (STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL), an informational banner explains that file-based log viewing is unavailable.
- The log file path is validated against a safe prefix allowlist on the backend to prevent path-traversal reads.
---
## 7. Ban History

View File

@@ -41,9 +41,17 @@ Remove all inactive-jail display and activation UI from the Jail management page
## Task 2 — Configuration Subpage: fail2ban Log Viewer & Service Health
**Status:** not started
**Status:** done
**References:** [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
**Implementation summary:**
- Added `Fail2BanLogResponse` and `ServiceStatusResponse` Pydantic models to `backend/app/models/config.py`.
- Added `read_fail2ban_log()` and `get_service_status()` service methods to `backend/app/services/config_service.py`. The log method queries the fail2ban socket for the log target/level, validates the resolved path against a safe-prefix allowlist (`/var/log`), then reads the tail of the file. Uses `Promise.allSettled` on the frontend so a log-read failure never hides the service-health panel.
- Added `GET /api/config/fail2ban-log` and `GET /api/config/service-status` endpoints to `backend/app/routers/config.py`.
- Created `frontend/src/components/config/LogTab.tsx` with service-health panel and scrollable log viewer, color-coded by severity, with filter/lines/refresh/auto-refresh controls.
- Updated `ConfigPage.tsx` to register the new "Log" tab.
- Added backend service tests (8 in 2 classes) and router tests (11 in 2 classes). Added frontend component tests (8 tests). All pass. ruff, mypy (modified files), tsc, eslint, and vitest all green.
### Problem
There is currently no way to view the fail2ban daemon log (`/var/log/fail2ban.log` or wherever the log target is configured) through the web interface. There is also no dedicated place in the Configuration section that shows at a glance whether fail2ban is running correctly. The existing health probe (`health_service.py`) and dashboard status bar give connectivity info, but the Configuration page should have its own panel showing service health alongside the raw log output.
@@ -255,3 +263,124 @@ Build a multi-layered safety net that:
- All new endpoints have tests covering success, failure, and edge cases.
---
## Task 4 — Jail Detail Page: Paginated "Currently Banned IPs" List
**Status:** not started
**References:** [Features.md § 5 — Jail Management](Features.md), [Architekture.md § 2](Architekture.md)
### Problem
The Jail detail page (`JailDetailPage.tsx`) currently shows "Currently banned: N" as a single number inside the stats grid. There is no way to see **which** IPs are banned in the jail — only the count. The global `GET /api/bans/active` endpoint fetches banned IPs across all jails at once without pagination, which is both wasteful (queries every jail) and slow when thousands of IPs are banned. There is no jail-specific endpoint to retrieve just the banned IPs for a single jail.
### Goal
Add a **"Currently Banned IPs"** section to the Jail detail page that displays the banned IPs for that specific jail in a paginated table. The implementation must be fast: the backend paginates on the server side so only one page of data is sent over the wire at a time, and geo enrichment is performed only for the IPs in the current page.
### Backend Changes
#### New Endpoint: Jail-Specific Active Bans (Paginated)
1. **Create `GET /api/jails/{name}/banned`** in `backend/app/routers/jails.py`.
- **Path parameter:** `name` (str) — jail name.
- **Query parameters:**
- `page` (int, default 1, min 1) — current page number.
- `page_size` (int, default 25, min 1, max 100) — items per page.
- `search` (optional str) — plain-text substring filter on the IP address (for searching).
- **Response model:** `JailBannedIpsResponse` (new model, see below).
- **Behaviour:**
- Query the fail2ban socket with `get <jail> banip --with-time` to get the full list of banned IPs for this single jail.
- Parse each entry using the existing `_parse_ban_entry()` helper.
- If a `search` parameter is provided, filter the parsed list to entries where the IP contains the search substring.
- Compute `total` (length of the filtered list).
- Slice the list to extract only the requested page: `items[(page-1)*page_size : page*page_size]`.
- Geo-enrich **only** the IPs in the current page slice using `geo_service.lookup_batch()` — this is the key performance optimisation (never enrich thousands of IPs at once).
- Return the paginated response.
- **Error handling:** Return 404 if the jail does not exist. Return 502 if fail2ban is unreachable.
2. **Create the service method** `get_jail_banned_ips()` in `backend/app/services/jail_service.py`.
- Accept parameters: `socket_path`, `jail_name`, `page`, `page_size`, `search`, `http_session`, `app_db`.
- Open a `Fail2BanClient` connection and send `["get", jail_name, "banip", "--with-time"]`.
- Parse the result with `_parse_ban_entry()`.
- Apply optional search filter (case-insensitive substring match on `ip`).
- Slice the list for the requested page.
- Run `geo_service.lookup_batch()` on only the page slice.
- Return the `JailBannedIpsResponse`.
3. **Create Pydantic models** in `backend/app/models/ban.py`:
- `JailBannedIpsResponse`:
```python
class JailBannedIpsResponse(BaseModel):
items: list[ActiveBan]
total: int # total matching entries (after search filter)
page: int # current page number
page_size: int # items per page
```
### Frontend Changes
#### New Section: Currently Banned IPs
4. **Create `frontend/src/components/jail/BannedIpsSection.tsx`** — a self-contained component that receives the jail name as a prop and manages its own data fetching and pagination state.
- **Table columns:** IP Address, Country (flag + code), Banned At, Expires At, Actions (unban button).
- **Pagination controls** below the table: page number, previous/next buttons, page size selector (10, 25, 50, 100). Use FluentUI `DataGrid` or a simple custom table — keep it lightweight.
- **Search input** above the table: a text field that debounces input (300ms) and re-fetches with the `search` query parameter. Debounce to avoid spamming the backend on each keystroke.
- **Loading state:** Show a spinner inside the table area while fetching. Do not block the rest of the page.
- **Empty state:** When no IPs are banned, show a muted "No IPs currently banned" message.
- **Unban action:** Each row has an unban button. On click, call the existing `DELETE /api/bans` endpoint with the IP and jail name, then re-fetch the current page.
- **Auto-refresh:** Do not auto-refresh. The user can click a manual refresh button in the section header.
5. **Mount the section** in `JailDetailPage.tsx`.
- Add `<BannedIpsSection jailName={jail.name} />` after the `JailInfoSection` (stats grid) and before the `PatternsSection`.
- The section should have a header: "Currently Banned IPs" with the total count as a badge next to it.
6. **Create API function** in `frontend/src/api/jails.ts`:
- `fetchJailBannedIps(jailName: string, page?: number, pageSize?: number, search?: string): Promise<JailBannedIpsResponse>`
- Calls `GET /api/jails/{name}/banned?page=...&page_size=...&search=...`.
7. **Create TypeScript types** in `frontend/src/types/jail.ts`:
- `JailBannedIpsResponse { items: ActiveBan[]; total: number; page: number; page_size: number; }`
- Reuse the existing `ActiveBan` type from `frontend/src/types/jail.ts`.
### Performance Considerations
- The fail2ban socket command `get <jail> banip --with-time` returns the full list; there is no socket-level pagination. Pagination is applied **after** parsing the socket response. This is acceptable because parsing string entries is fast even for 10,000+ IPs — the expensive part is geo enrichment and network transfer, both of which are limited to the page size.
- Geo enrichment (`lookup_batch`) is called **only** for the page slice (max 100 IPs). This avoids hitting ip-api.com rate limits and keeps response times low.
- The `search` filter runs server-side on the already-parsed list (simple substring match) before slicing, so the `total` count reflects the filtered result.
- Frontend debounces the search input to avoid redundant API calls.
### Backend File Map
| File | Changes |
|---|---|
| `routers/jails.py` | Add `GET /api/jails/{name}/banned` endpoint. |
| `services/jail_service.py` | Add `get_jail_banned_ips()` with pagination, search, and page-only geo enrichment. |
| `models/ban.py` | Add `JailBannedIpsResponse`. |
### Frontend File Map
| File | Changes |
|---|---|
| `components/jail/BannedIpsSection.tsx` (new) | Paginated table with search, unban action, refresh button. |
| `pages/JailDetailPage.tsx` | Mount `BannedIpsSection` after the stats grid. |
| `api/jails.ts` | Add `fetchJailBannedIps()`. |
| `types/jail.ts` | Add `JailBannedIpsResponse`. |
### Tests
8. **Backend:** Test `get_jail_banned_ips()` — mock the socket response, verify pagination slicing (page 1 returns first N items, page 2 returns the next N), verify total count, verify search filter narrows results, verify geo enrichment is called with only the page slice.
9. **Backend:** Test `GET /api/jails/{name}/banned` endpoint — 200 with paginated data, 404 for unknown jail, 502 when fail2ban is down, search parameter works, page_size clamped to max 100.
10. **Frontend:** Test `BannedIpsSection` — renders table with IP rows, pagination buttons navigate pages, search input triggers re-fetch with debounce, unban button calls the API and refreshes, empty state shown when no bans.
### Acceptance Criteria
- The Jail detail page shows a "Currently Banned IPs" section with a paginated table below the stats grid.
- Only one page of IPs is fetched from the backend at a time; geo enrichment runs only for that page.
- Users can paginate through the list and change the page size (10, 25, 50, 100).
- Users can search/filter by IP address substring; results update after a debounce delay.
- Each row has an unban button that removes the ban and refreshes the current page.
- Response times stay fast (<500ms) even when a jail has thousands of banned IPs (since only one page is geo-enriched).
- The section shows a clear empty state when no IPs are banned.
- All new backend endpoints and frontend components have test coverage.
---

View File

@@ -860,3 +860,34 @@ class JailActivationResponse(BaseModel):
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
)
message: str = Field(..., description="Human-readable result message.")
# ---------------------------------------------------------------------------
# fail2ban log viewer models
# ---------------------------------------------------------------------------
class Fail2BanLogResponse(BaseModel):
"""Response for ``GET /api/config/fail2ban-log``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Resolved absolute path of the log file being read.")
lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).")
total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.")
log_level: str = Field(..., description="Current fail2ban log level.")
log_target: str = Field(..., description="Current fail2ban log target (file path or special value).")
class ServiceStatusResponse(BaseModel):
"""Response for ``GET /api/config/service-status``."""
model_config = ConfigDict(strict=True)
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.")
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.")
log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.")

View File

@@ -28,6 +28,8 @@ global settings, test regex patterns, add log paths, and preview log files.
* ``PUT /api/config/actions/{name}`` — update an action's .local override
* ``POST /api/config/actions`` — create a new user-defined action
* ``DELETE /api/config/actions/{name}`` — delete an action's .local file
* ``GET /api/config/fail2ban-log`` — read the tail of the fail2ban log file
* ``GET /api/config/service-status`` — fail2ban health + log configuration
"""
from __future__ import annotations
@@ -46,6 +48,7 @@ from app.models.config import (
AddLogPathRequest,
AssignActionRequest,
AssignFilterRequest,
Fail2BanLogResponse,
FilterConfig,
FilterCreateRequest,
FilterListResponse,
@@ -63,6 +66,7 @@ from app.models.config import (
MapColorThresholdsUpdate,
RegexTestRequest,
RegexTestResponse,
ServiceStatusResponse,
)
from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import (
@@ -1319,3 +1323,83 @@ async def remove_action_from_jail(
detail=f"Failed to write jail override: {exc}",
) from exc
# ---------------------------------------------------------------------------
# fail2ban log viewer endpoints
# ---------------------------------------------------------------------------
@router.get(
"/fail2ban-log",
response_model=Fail2BanLogResponse,
summary="Read the tail of the fail2ban daemon log file",
)
async def get_fail2ban_log(
request: Request,
_auth: AuthDep,
lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200,
filter: Annotated[ # noqa: A002
str | None,
Query(description="Plain-text substring filter; only matching lines are returned."),
] = None,
) -> Fail2BanLogResponse:
"""Return the tail of the fail2ban daemon log file.
Queries the fail2ban socket for the current log target and log level,
reads the last *lines* entries from the file, and optionally filters
them by *filter*. Only file-based log targets are supported.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
lines: Number of tail lines to return (12000, default 200).
filter: Optional plain-text substring — only matching lines returned.
Returns:
:class:`~app.models.config.Fail2BanLogResponse`.
Raises:
HTTPException: 400 when the log target is not a file or path is outside
the allowed directory.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.read_fail2ban_log(socket_path, lines, filter)
except config_service.ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.get(
"/service-status",
response_model=ServiceStatusResponse,
summary="Return fail2ban service health status with log configuration",
)
async def get_service_status(
request: Request,
_auth: AuthDep,
) -> ServiceStatusResponse:
"""Return fail2ban service health and current log configuration.
Probes the fail2ban daemon to determine online/offline state, then
augments the result with the current log level and log target values.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.ServiceStatusResponse`.
Raises:
HTTPException: 502 when fail2ban is unreachable (the service itself
handles this gracefully and returns ``online=False``).
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.get_service_status(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -26,6 +26,7 @@ if TYPE_CHECKING:
from app.models.config import (
AddLogPathRequest,
BantimeEscalation,
Fail2BanLogResponse,
GlobalConfigResponse,
GlobalConfigUpdate,
JailConfig,
@@ -39,6 +40,7 @@ from app.models.config import (
MapColorThresholdsUpdate,
RegexTestRequest,
RegexTestResponse,
ServiceStatusResponse,
)
from app.services import setup_service
from app.utils.fail2ban_client import Fail2BanClient
@@ -754,3 +756,174 @@ async def update_map_color_thresholds(
threshold_medium=update.threshold_medium,
threshold_low=update.threshold_low,
)
# ---------------------------------------------------------------------------
# fail2ban log file reader
# ---------------------------------------------------------------------------
# Log targets that are not file paths — log viewing is unavailable for these.
_NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
{"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"}
)
# Only allow reading log files under these base directories (security).
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log",)
def _count_file_lines(file_path: str) -> int:
"""Count the total number of lines in *file_path* synchronously.
Uses a memory-efficient buffered read to avoid loading the whole file.
Args:
file_path: Absolute path to the file.
Returns:
Total number of lines in the file.
"""
count = 0
with open(file_path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
count += chunk.count(b"\n")
return count
async def read_fail2ban_log(
socket_path: str,
lines: int,
filter_text: str | None = None,
) -> Fail2BanLogResponse:
"""Read the tail of the fail2ban daemon log file.
Queries the fail2ban socket for the current log target and log level,
validates that the target is a readable file, then returns the last
*lines* entries optionally filtered by *filter_text*.
Security: the resolved log path is rejected unless it starts with one of
the paths in :data:`_SAFE_LOG_PREFIXES`, preventing path traversal.
Args:
socket_path: Path to the fail2ban Unix domain socket.
lines: Number of lines to return from the tail of the file (12000).
filter_text: Optional plain-text substring — only matching lines are
returned. Applied server-side; does not affect *total_lines*.
Returns:
:class:`~app.models.config.Fail2BanLogResponse`.
Raises:
ConfigOperationError: When the log target is not a file, when the
resolved path is outside the allowed directories, or when the
file cannot be read.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get(client, ["get", "loglevel"], "INFO"),
_safe_get(client, ["get", "logtarget"], "STDOUT"),
)
log_level = str(log_level_raw or "INFO").upper()
log_target = str(log_target_raw or "STDOUT")
# Reject non-file targets up front.
if log_target.upper() in _NON_FILE_LOG_TARGETS:
raise ConfigOperationError(
f"fail2ban is logging to {log_target!r}. "
"File-based log viewing is only available when fail2ban logs to a file path."
)
# Resolve and validate (security: no path traversal outside safe dirs).
try:
resolved = Path(log_target).resolve()
except (ValueError, OSError) as exc:
raise ConfigOperationError(
f"Cannot resolve log target path {log_target!r}: {exc}"
) from exc
resolved_str = str(resolved)
if not any(resolved_str.startswith(safe) for safe in _SAFE_LOG_PREFIXES):
raise ConfigOperationError(
f"Log path {resolved_str!r} is outside the allowed directory. "
"Only paths under /var/log are permitted."
)
if not resolved.is_file():
raise ConfigOperationError(f"Log file not found: {resolved_str!r}")
loop = asyncio.get_event_loop()
total_lines, raw_lines = await asyncio.gather(
loop.run_in_executor(None, _count_file_lines, resolved_str),
loop.run_in_executor(None, _read_tail_lines, resolved_str, lines),
)
filtered = (
[ln for ln in raw_lines if filter_text in ln]
if filter_text
else raw_lines
)
log.info(
"fail2ban_log_read",
log_path=resolved_str,
lines_requested=lines,
lines_returned=len(filtered),
filter_active=filter_text is not None,
)
return Fail2BanLogResponse(
log_path=resolved_str,
lines=filtered,
total_lines=total_lines,
log_level=log_level,
log_target=log_target,
)
async def get_service_status(socket_path: str) -> ServiceStatusResponse:
"""Return fail2ban service health status with log configuration.
Delegates to :func:`~app.services.health_service.probe` for the core
health snapshot and augments it with the current log-level and log-target
values from the socket.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.ServiceStatusResponse`.
"""
from app.services.health_service import probe # lazy import avoids circular dep
server_status = await probe(socket_path)
if server_status.online:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get(client, ["get", "loglevel"], "INFO"),
_safe_get(client, ["get", "logtarget"], "STDOUT"),
)
log_level = str(log_level_raw or "INFO").upper()
log_target = str(log_target_raw or "STDOUT")
else:
log_level = "UNKNOWN"
log_target = "UNKNOWN"
log.info(
"service_status_fetched",
online=server_status.online,
jail_count=server_status.active_jails,
)
return ServiceStatusResponse(
online=server_status.online,
version=server_status.version,
jail_count=server_status.active_jails,
total_bans=server_status.total_bans,
total_failures=server_status.total_failures,
log_level=log_level,
log_target=log_target,
)

View File

@@ -13,12 +13,14 @@ from app.config import Settings
from app.db import init_db
from app.main import create_app
from app.models.config import (
Fail2BanLogResponse,
FilterConfig,
GlobalConfigResponse,
JailConfig,
JailConfigListResponse,
JailConfigResponse,
RegexTestResponse,
ServiceStatusResponse,
)
# ---------------------------------------------------------------------------
@@ -1711,3 +1713,164 @@ class TestRemoveActionFromJailRouter:
).delete("/api/config/jails/sshd/action/iptables")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/config/fail2ban-log
# ---------------------------------------------------------------------------
class TestGetFail2BanLog:
"""Tests for ``GET /api/config/fail2ban-log``."""
def _mock_log_response(self) -> Fail2BanLogResponse:
return Fail2BanLogResponse(
log_path="/var/log/fail2ban.log",
lines=["2025-01-01 INFO sshd Found 1.2.3.4", "2025-01-01 ERROR oops"],
total_lines=100,
log_level="INFO",
log_target="/var/log/fail2ban.log",
)
async def test_200_returns_log_response(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 200 with Fail2BanLogResponse."""
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(return_value=self._mock_log_response()),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 200
data = resp.json()
assert data["log_path"] == "/var/log/fail2ban.log"
assert isinstance(data["lines"], list)
assert data["total_lines"] == 100
assert data["log_level"] == "INFO"
async def test_200_passes_lines_query_param(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log passes the lines query param to the service."""
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(return_value=self._mock_log_response()),
) as mock_fn:
resp = await config_client.get("/api/config/fail2ban-log?lines=500")
assert resp.status_code == 200
_socket, lines_arg, _filter = mock_fn.call_args.args
assert lines_arg == 500
async def test_200_passes_filter_query_param(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log passes the filter query param to the service."""
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(return_value=self._mock_log_response()),
) as mock_fn:
resp = await config_client.get("/api/config/fail2ban-log?filter=ERROR")
assert resp.status_code == 200
_socket, _lines, filter_arg = mock_fn.call_args.args
assert filter_arg == "ERROR"
async def test_400_when_non_file_target(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 400 when log target is not a file."""
from app.services.config_service import ConfigOperationError
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(side_effect=ConfigOperationError("fail2ban is logging to 'STDOUT'")),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 400
async def test_400_when_path_traversal_detected(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 400 when the path is outside safe dirs."""
from app.services.config_service import ConfigOperationError
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(side_effect=ConfigOperationError("outside the allowed directory")),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 400
async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 502 when fail2ban is down."""
from app.utils.fail2ban_client import Fail2BanConnectionError
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(side_effect=Fail2BanConnectionError("socket error", "/tmp/f.sock")),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 502
async def test_422_for_lines_exceeding_max(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 422 for lines > 2000."""
resp = await config_client.get("/api/config/fail2ban-log?lines=9999")
assert resp.status_code == 422
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log requires authentication."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/fail2ban-log")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/config/service-status
# ---------------------------------------------------------------------------
class TestGetServiceStatus:
"""Tests for ``GET /api/config/service-status``."""
def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
return ServiceStatusResponse(
online=online,
version="1.0.0" if online else None,
jail_count=2 if online else 0,
total_bans=10 if online else 0,
total_failures=3 if online else 0,
log_level="INFO" if online else "UNKNOWN",
log_target="/var/log/fail2ban.log" if online else "UNKNOWN",
)
async def test_200_when_online(self, config_client: AsyncClient) -> None:
"""GET /api/config/service-status returns 200 with full status when online."""
with patch(
"app.routers.config.config_service.get_service_status",
AsyncMock(return_value=self._mock_status(online=True)),
):
resp = await config_client.get("/api/config/service-status")
assert resp.status_code == 200
data = resp.json()
assert data["online"] is True
assert data["jail_count"] == 2
assert data["log_level"] == "INFO"
async def test_200_when_offline(self, config_client: AsyncClient) -> None:
"""GET /api/config/service-status returns 200 with offline=False when daemon is down."""
with patch(
"app.routers.config.config_service.get_service_status",
AsyncMock(return_value=self._mock_status(online=False)),
):
resp = await config_client.get("/api/config/service-status")
assert resp.status_code == 200
data = resp.json()
assert data["online"] is False
assert data["log_level"] == "UNKNOWN"
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/service-status requires authentication."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/service-status")
assert resp.status_code == 401

View File

@@ -604,3 +604,145 @@ class TestPreviewLog:
result = await config_service.preview_log(req)
assert result.total_lines <= 50
# ---------------------------------------------------------------------------
# read_fail2ban_log
# ---------------------------------------------------------------------------
class TestReadFail2BanLog:
"""Tests for :func:`config_service.read_fail2ban_log`."""
def _patch_client(self, log_level: str = "INFO", log_target: str = "/var/log/fail2ban.log") -> Any:
"""Build a patched Fail2BanClient that returns *log_level* and *log_target*."""
async def _send(command: list[Any]) -> Any:
key = "|".join(str(c) for c in command)
if key == "get|loglevel":
return (0, log_level)
if key == "get|logtarget":
return (0, log_target)
return (0, None)
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
return patch("app.services.config_service.Fail2BanClient", _FakeClient)
async def test_returns_log_lines_from_file(self, tmp_path: Any) -> None:
"""read_fail2ban_log returns lines from the file and counts totals."""
log_file = tmp_path / "fail2ban.log"
log_file.write_text("line1\nline2\nline3\n")
log_dir = str(tmp_path)
# Patch _SAFE_LOG_PREFIXES to allow tmp_path
with self._patch_client(log_target=str(log_file)), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)):
result = await config_service.read_fail2ban_log(_SOCKET, 200)
assert result.log_path == str(log_file.resolve())
assert result.total_lines >= 3
assert any("line1" in ln for ln in result.lines)
assert result.log_level == "INFO"
async def test_filter_narrows_returned_lines(self, tmp_path: Any) -> None:
"""read_fail2ban_log filters lines by substring."""
log_file = tmp_path / "fail2ban.log"
log_file.write_text("INFO sshd Found 1.2.3.4\nERROR something else\nINFO sshd Found 5.6.7.8\n")
log_dir = str(tmp_path)
with self._patch_client(log_target=str(log_file)), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)):
result = await config_service.read_fail2ban_log(_SOCKET, 200, "Found")
assert all("Found" in ln for ln in result.lines)
assert result.total_lines >= 3 # total is unfiltered
async def test_non_file_target_raises_operation_error(self) -> None:
"""read_fail2ban_log raises ConfigOperationError for STDOUT target."""
with self._patch_client(log_target="STDOUT"), \
pytest.raises(config_service.ConfigOperationError, match="STDOUT"):
await config_service.read_fail2ban_log(_SOCKET, 200)
async def test_syslog_target_raises_operation_error(self) -> None:
"""read_fail2ban_log raises ConfigOperationError for SYSLOG target."""
with self._patch_client(log_target="SYSLOG"), \
pytest.raises(config_service.ConfigOperationError, match="SYSLOG"):
await config_service.read_fail2ban_log(_SOCKET, 200)
async def test_path_outside_safe_dir_raises_operation_error(self, tmp_path: Any) -> None:
"""read_fail2ban_log rejects a log_target outside allowed directories."""
log_file = tmp_path / "secret.log"
log_file.write_text("secret data\n")
# Allow only /var/log — tmp_path is deliberately not in the safe list.
with self._patch_client(log_target=str(log_file)), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", ("/var/log",)), \
pytest.raises(config_service.ConfigOperationError, match="outside the allowed"):
await config_service.read_fail2ban_log(_SOCKET, 200)
async def test_missing_log_file_raises_operation_error(self, tmp_path: Any) -> None:
"""read_fail2ban_log raises ConfigOperationError when the file does not exist."""
missing = str(tmp_path / "nonexistent.log")
log_dir = str(tmp_path)
with self._patch_client(log_target=missing), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)), \
pytest.raises(config_service.ConfigOperationError, match="not found"):
await config_service.read_fail2ban_log(_SOCKET, 200)
# ---------------------------------------------------------------------------
# get_service_status
# ---------------------------------------------------------------------------
class TestGetServiceStatus:
"""Tests for :func:`config_service.get_service_status`."""
async def test_online_status_includes_log_config(self) -> None:
"""get_service_status returns correct fields when fail2ban is online."""
from app.models.server import ServerStatus
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, "DEBUG")
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), \
patch("app.services.health_service.probe", AsyncMock(return_value=online_status)):
result = await config_service.get_service_status(_SOCKET)
assert result.online is True
assert result.version == "1.0.0"
assert result.jail_count == 2
assert result.total_bans == 5
assert result.total_failures == 3
assert result.log_level == "DEBUG"
assert result.log_target == "/var/log/fail2ban.log"
async def test_offline_status_returns_unknown_log_fields(self) -> None:
"""get_service_status returns 'UNKNOWN' log fields when fail2ban is offline."""
from app.models.server import ServerStatus
offline_status = ServerStatus(online=False)
with patch("app.services.health_service.probe", AsyncMock(return_value=offline_status)):
result = await config_service.get_service_status(_SOCKET)
assert result.online is False
assert result.jail_count == 0
assert result.log_level == "UNKNOWN"
assert result.log_target == "UNKNOWN"

View File

@@ -18,6 +18,7 @@ import type {
ConfFileCreateRequest,
ConfFilesResponse,
ConfFileUpdateRequest,
Fail2BanLogResponse,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
@@ -43,6 +44,7 @@ import type {
ServerSettingsUpdate,
JailFileConfig,
JailFileConfigUpdate,
ServiceStatusResponse,
} from "../types/config";
// ---------------------------------------------------------------------------
@@ -541,3 +543,29 @@ export async function deactivateJail(
undefined
);
}
// ---------------------------------------------------------------------------
// fail2ban log viewer (Task 2)
// ---------------------------------------------------------------------------
/**
* Fetch the tail of the fail2ban daemon log file.
*
* @param lines - Number of tail lines to return (12000, default 200).
* @param filter - Optional plain-text substring; only matching lines returned.
*/
export async function fetchFail2BanLog(
lines?: number,
filter?: string,
): Promise<Fail2BanLogResponse> {
const params = new URLSearchParams();
if (lines !== undefined) params.set("lines", String(lines));
if (filter !== undefined && filter !== "") params.set("filter", filter);
const query = params.toString() ? `?${params.toString()}` : "";
return get<Fail2BanLogResponse>(`${ENDPOINTS.configFail2BanLog}${query}`);
}
/** Fetch fail2ban service health status with current log configuration. */
export async function fetchServiceStatus(): Promise<ServiceStatusResponse> {
return get<ServiceStatusResponse>(ENDPOINTS.configServiceStatus);
}

View File

@@ -100,6 +100,10 @@ export const ENDPOINTS = {
configActionParsed: (name: string): string =>
`/config/actions/${encodeURIComponent(name)}/parsed`,
// fail2ban log viewer (Task 2)
configFail2BanLog: "/config/fail2ban-log",
configServiceStatus: "/config/service-status",
// -------------------------------------------------------------------------
// Server settings
// -------------------------------------------------------------------------

View File

@@ -0,0 +1,518 @@
/**
* LogTab — fail2ban log viewer and service health panel.
*
* Renders two sections:
* 1. **Service Health panel** — shows online/offline state, version, active
* jail count, total bans, total failures, log level, and log target.
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
* toolbar controls for line count, substring filter, manual refresh, and
* optional auto-refresh. Log lines are color-coded by severity.
*/
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
Badge,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
Switch,
Text,
makeStyles,
tokens,
} from "@fluentui/react-components";
import {
ArrowClockwise24Regular,
DocumentBulletList24Regular,
Filter24Regular,
} from "@fluentui/react-icons";
import { fetchFail2BanLog, fetchServiceStatus } from "../../api/config";
import { useConfigStyles } from "./configStyles";
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../types/config";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Available line-count options for the log tail. */
const LINE_COUNT_OPTIONS: number[] = [100, 200, 500, 1000];
/** Auto-refresh interval options in seconds. */
const AUTO_REFRESH_INTERVALS: { label: string; value: number }[] = [
{ label: "5 s", value: 5 },
{ label: "10 s", value: 10 },
{ label: "30 s", value: 30 },
];
/** Debounce delay for the filter input in milliseconds. */
const FILTER_DEBOUNCE_MS = 300;
/** Log targets that are not file paths — file-based viewing is unavailable. */
const NON_FILE_TARGETS = new Set(["STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"]);
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
healthGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
gap: tokens.spacingHorizontalM,
marginTop: tokens.spacingVerticalM,
},
statCard: {
backgroundColor: tokens.colorNeutralBackground3,
borderRadius: tokens.borderRadiusMedium,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
statLabel: {
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200,
},
statValue: {
fontWeight: tokens.fontWeightSemibold,
fontSize: tokens.fontSizeBase300,
},
metaRow: {
display: "flex",
gap: tokens.spacingHorizontalL,
marginTop: tokens.spacingVerticalS,
flexWrap: "wrap",
},
metaItem: {
display: "flex",
flexDirection: "column",
gap: "2px",
},
toolbar: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
flexWrap: "wrap",
marginBottom: tokens.spacingVerticalM,
},
filterInput: {
width: "200px",
},
logContainer: {
backgroundColor: tokens.colorNeutralBackground4,
borderRadius: tokens.borderRadiusMedium,
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
maxHeight: "560px",
overflowY: "auto",
fontFamily: "monospace",
fontSize: tokens.fontSizeBase200,
lineHeight: "1.6",
border: `1px solid ${tokens.colorNeutralStroke2}`,
},
logLineError: {
color: tokens.colorPaletteRedForeground1,
},
logLineWarning: {
color: tokens.colorPaletteYellowForeground1,
},
logLineDebug: {
color: tokens.colorNeutralForeground4,
},
logLineDefault: {
color: tokens.colorNeutralForeground1,
},
truncatedBanner: {
marginBottom: tokens.spacingVerticalS,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase100,
},
emptyLog: {
color: tokens.colorNeutralForeground3,
fontStyle: "italic",
padding: tokens.spacingVerticalS,
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Determine the CSS class key for a log line based on its severity.
*
* fail2ban formats log lines as:
* ``2025-… fail2ban.filter [PID]: LEVEL message``
*
* @param line - A single log line string.
* @returns The severity key: "error" | "warning" | "debug" | "default".
*/
function detectSeverity(line: string): "error" | "warning" | "debug" | "default" {
const upper = line.toUpperCase();
if (upper.includes(" ERROR ") || upper.includes(" CRITICAL ")) return "error";
if (upper.includes(" WARNING ") || upper.includes(": WARNING") || upper.includes(" WARN ")) return "warning";
if (upper.includes(" DEBUG ")) return "debug";
return "default";
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Log tab component for the Configuration page.
*
* Shows fail2ban service health and a live log viewer with refresh controls.
*
* @returns JSX element.
*/
export function LogTab(): React.JSX.Element {
const configStyles = useConfigStyles();
const styles = useStyles();
// ---- data state ----------------------------------------------------------
const [status, setStatus] = useState<ServiceStatusResponse | null>(null);
const [logData, setLogData] = useState<Fail2BanLogResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// ---- toolbar state -------------------------------------------------------
const [linesCount, setLinesCount] = useState<number>(200);
const [filterRaw, setFilterRaw] = useState<string>("");
const [filterValue, setFilterValue] = useState<string>("");
const [autoRefresh, setAutoRefresh] = useState(false);
const [refreshInterval, setRefreshInterval] = useState(10);
// ---- refs ----------------------------------------------------------------
const logContainerRef = useRef<HTMLDivElement>(null);
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ---- scroll helper -------------------------------------------------------
const scrollToBottom = useCallback((): void => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, []);
// ---- fetch logic ---------------------------------------------------------
const fetchData = useCallback(
async (showSpinner: boolean): Promise<void> => {
if (showSpinner) setIsRefreshing(true);
try {
// Use allSettled so a log-read failure doesn't hide the service status.
const [svcResult, logResult] = await Promise.allSettled([
fetchServiceStatus(),
fetchFail2BanLog(linesCount, filterValue || undefined),
]);
if (svcResult.status === "fulfilled") {
setStatus(svcResult.value);
} else {
setStatus(null);
}
if (logResult.status === "fulfilled") {
setLogData(logResult.value);
setError(null);
} else {
const reason: unknown = logResult.reason;
setError(reason instanceof Error ? reason.message : "Failed to load log data.");
}
} finally {
if (showSpinner) setIsRefreshing(false);
setLoading(false);
}
},
[linesCount, filterValue],
);
// ---- initial load --------------------------------------------------------
useEffect(() => {
setLoading(true);
void fetchData(false);
}, [fetchData]);
// ---- scroll to bottom when new log data arrives -------------------------
useEffect(() => {
if (logData) {
// Tiny timeout lets the DOM paint before scrolling.
const t = setTimeout(scrollToBottom, 50);
return (): void => { clearTimeout(t); };
}
}, [logData, scrollToBottom]);
// ---- auto-refresh interval ----------------------------------------------
useEffect(() => {
if (autoRefreshTimerRef.current) {
clearInterval(autoRefreshTimerRef.current);
autoRefreshTimerRef.current = null;
}
if (autoRefresh) {
autoRefreshTimerRef.current = setInterval(() => {
void fetchData(true);
}, refreshInterval * 1000);
}
return (): void => {
if (autoRefreshTimerRef.current) {
clearInterval(autoRefreshTimerRef.current);
}
};
}, [autoRefresh, refreshInterval, fetchData]);
// ---- filter debounce ----------------------------------------------------
const handleFilterChange = useCallback((value: string): void => {
setFilterRaw(value);
if (filterDebounceRef.current) clearTimeout(filterDebounceRef.current);
filterDebounceRef.current = setTimeout(() => {
setFilterValue(value);
}, FILTER_DEBOUNCE_MS);
}, []);
// ---- render helpers ------------------------------------------------------
const renderLogLine = (line: string, idx: number): React.JSX.Element => {
const severity = detectSeverity(line);
const className =
severity === "error"
? styles.logLineError
: severity === "warning"
? styles.logLineWarning
: severity === "debug"
? styles.logLineDebug
: styles.logLineDefault;
return (
<div key={idx} className={className}>
{line}
</div>
);
};
// ---- loading state -------------------------------------------------------
if (loading) {
return <Spinner label="Loading log viewer…" />;
}
// ---- error state ---------------------------------------------------------
if (error && !status && !logData) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
const isNonFileTarget =
logData?.log_target != null &&
NON_FILE_TARGETS.has(logData.log_target.toUpperCase());
const isTruncated =
logData != null && logData.total_lines > logData.lines.length;
return (
<div>
{/* ------------------------------------------------------------------ */}
{/* Service Health Panel */}
{/* ------------------------------------------------------------------ */}
<div className={configStyles.sectionCard}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<DocumentBulletList24Regular />
<Text weight="semibold" size={400}>
Service Health
</Text>
{status?.online ? (
<Badge appearance="filled" color="success">
Running
</Badge>
) : (
<Badge appearance="filled" color="danger">
Offline
</Badge>
)}
</div>
{status && !status.online && (
<MessageBar intent="warning" style={{ marginTop: tokens.spacingVerticalM }}>
<MessageBarBody>
fail2ban is not running or unreachable. Check the server and socket
configuration.
</MessageBarBody>
</MessageBar>
)}
{status?.online && (
<>
<div className={styles.healthGrid}>
{status.version && (
<div className={styles.statCard}>
<Text className={styles.statLabel}>Version</Text>
<Text className={styles.statValue}>{status.version}</Text>
</div>
)}
<div className={styles.statCard}>
<Text className={styles.statLabel}>Active Jails</Text>
<Text className={styles.statValue}>{status.jail_count}</Text>
</div>
<div className={styles.statCard}>
<Text className={styles.statLabel}>Currently Banned</Text>
<Text className={styles.statValue}>{status.total_bans}</Text>
</div>
<div className={styles.statCard}>
<Text className={styles.statLabel}>Currently Failed</Text>
<Text className={styles.statValue}>{status.total_failures}</Text>
</div>
</div>
<div className={styles.metaRow}>
<div className={styles.metaItem}>
<Text className={styles.statLabel}>Log Level</Text>
<Text size={300}>{status.log_level}</Text>
</div>
<div className={styles.metaItem}>
<Text className={styles.statLabel}>Log Target</Text>
<Text size={300}>{status.log_target}</Text>
</div>
</div>
</>
)}
</div>
{/* ------------------------------------------------------------------ */}
{/* Log Viewer */}
{/* ------------------------------------------------------------------ */}
<div className={configStyles.sectionCard}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
<Text weight="semibold" size={400}>
Log Viewer
</Text>
{logData && (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
{logData.log_path}
</Text>
)}
</div>
{/* Non-file target info banner */}
{isNonFileTarget && (
<MessageBar intent="info" style={{ marginBottom: tokens.spacingVerticalM }}>
<MessageBarBody>
fail2ban is logging to <strong>{logData.log_target}</strong>.
File-based log viewing is not available.
</MessageBarBody>
</MessageBar>
)}
{/* Toolbar — only shown when log data is available */}
{!isNonFileTarget && (
<>
<div className={styles.toolbar}>
{/* Filter input */}
<Field label="Filter">
<Input
className={styles.filterInput}
type="text"
value={filterRaw}
contentBefore={<Filter24Regular />}
placeholder="Substring filter…"
onChange={(_e, d) => { handleFilterChange(d.value); }}
/>
</Field>
{/* Lines count selector */}
<Field label="Lines">
<Select
value={String(linesCount)}
onChange={(_e, d) => { setLinesCount(Number(d.value)); }}
>
{LINE_COUNT_OPTIONS.map((n) => (
<option key={n} value={String(n)}>
{n}
</option>
))}
</Select>
</Field>
{/* Manual refresh */}
<div style={{ alignSelf: "flex-end" }}>
<Button
icon={<ArrowClockwise24Regular />}
appearance="secondary"
onClick={() => void fetchData(true)}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing…" : "Refresh"}
</Button>
</div>
{/* Auto-refresh toggle */}
<div style={{ alignSelf: "flex-end" }}>
<Switch
label="Auto-refresh"
checked={autoRefresh}
onChange={(_e, d) => { setAutoRefresh(d.checked); }}
/>
</div>
{/* Auto-refresh interval selector */}
{autoRefresh && (
<Field label="Interval">
<Select
value={String(refreshInterval)}
onChange={(_e, d) => { setRefreshInterval(Number(d.value)); }}
>
{AUTO_REFRESH_INTERVALS.map((opt) => (
<option key={opt.value} value={String(opt.value)}>
{opt.label}
</option>
))}
</Select>
</Field>
)}
</div>
{/* Truncation notice */}
{isTruncated && (
<Text className={styles.truncatedBanner} block>
Showing last {logData.lines.length} of {logData.total_lines} lines.
Increase the line count or use the filter to narrow results.
</Text>
)}
{/* Log lines container */}
<div className={styles.logContainer} ref={logContainerRef}>
{isRefreshing && (
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<Spinner size="tiny" label="Refreshing…" />
</div>
)}
{logData && logData.lines.length === 0 ? (
<div className={styles.emptyLog}>
{filterValue
? `No lines match the filter "${filterValue}".`
: "No log entries found."}
</div>
) : (
logData?.lines.map((line, idx) => renderLogLine(line, idx))
)}
</div>
{/* General fetch error */}
{error && (
<MessageBar intent="error" style={{ marginTop: tokens.spacingVerticalM }}>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
/**
* Tests for the LogTab component (Task 2).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { LogTab } from "../LogTab";
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchFail2BanLog: vi.fn(),
fetchServiceStatus: vi.fn(),
}));
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
const mockFetchLog = vi.mocked(fetchFail2BanLog);
const mockFetchStatus = vi.mocked(fetchServiceStatus);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const onlineStatus: ServiceStatusResponse = {
online: true,
version: "1.0.2",
jail_count: 3,
total_bans: 12,
total_failures: 5,
log_level: "INFO",
log_target: "/var/log/fail2ban.log",
};
const offlineStatus: ServiceStatusResponse = {
online: false,
version: null,
jail_count: 0,
total_bans: 0,
total_failures: 0,
log_level: "UNKNOWN",
log_target: "UNKNOWN",
};
const logResponse: Fail2BanLogResponse = {
log_path: "/var/log/fail2ban.log",
lines: [
"2025-01-01 12:00:00 INFO sshd Found 1.2.3.4",
"2025-01-01 12:00:01 WARNING sshd Too many failures",
"2025-01-01 12:00:02 ERROR fail2ban something went wrong",
],
total_lines: 1000,
log_level: "INFO",
log_target: "/var/log/fail2ban.log",
};
const nonFileLogResponse: Fail2BanLogResponse = {
...logResponse,
log_target: "STDOUT",
lines: [],
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTab() {
return render(
<FluentProvider theme={webLightTheme}>
<LogTab />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("LogTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a spinner while loading", () => {
// Never resolves during this test.
mockFetchStatus.mockReturnValue(new Promise(() => undefined));
mockFetchLog.mockReturnValue(new Promise(() => undefined));
renderTab();
expect(screen.getByText(/loading log viewer/i)).toBeInTheDocument();
});
it("renders the health panel with Running badge when online", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
renderTab();
await waitFor(() => expect(screen.queryByText(/loading log viewer/i)).toBeNull());
expect(screen.getByText("Running")).toBeInTheDocument();
expect(screen.getByText("1.0.2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument(); // active jails
expect(screen.getByText("12")).toBeInTheDocument(); // total bans
});
it("renders the Offline badge and warning when fail2ban is down", async () => {
mockFetchStatus.mockResolvedValue(offlineStatus);
mockFetchLog.mockRejectedValue(new Error("not running"));
renderTab();
await waitFor(() => expect(screen.queryByText(/loading log viewer/i)).toBeNull());
expect(screen.getByText("Offline")).toBeInTheDocument();
expect(screen.getByText(/not running or unreachable/i)).toBeInTheDocument();
});
it("renders log lines in the log viewer", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
renderTab();
await waitFor(() =>
expect(screen.getByText(/2025-01-01 12:00:00 INFO/)).toBeInTheDocument(),
);
expect(screen.getByText(/2025-01-01 12:00:01 WARNING/)).toBeInTheDocument();
expect(screen.getByText(/2025-01-01 12:00:02 ERROR/)).toBeInTheDocument();
});
it("shows a non-file target info banner when log_target is STDOUT", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(nonFileLogResponse);
renderTab();
await waitFor(() =>
expect(screen.getByText(/fail2ban is logging to/i)).toBeInTheDocument(),
);
expect(screen.getByText(/STDOUT/)).toBeInTheDocument();
expect(screen.queryByText(/Refresh/)).toBeNull();
});
it("shows empty state when no lines match the filter", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue({ ...logResponse, lines: [] });
renderTab();
await waitFor(() =>
expect(screen.getByText(/no log entries found/i)).toBeInTheDocument(),
);
});
it("shows truncation notice when total_lines > lines.length", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue({ ...logResponse, lines: logResponse.lines, total_lines: 1000 });
renderTab();
await waitFor(() =>
expect(screen.getByText(/showing last/i)).toBeInTheDocument(),
);
});
it("calls fetchFail2BanLog again on Refresh button click", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
const user = userEvent.setup();
renderTab();
await waitFor(() => expect(screen.getByText(/Refresh/)).toBeInTheDocument());
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
await user.click(refreshBtn);
await waitFor(() => expect(mockFetchLog).toHaveBeenCalledTimes(2));
});
});

View File

@@ -34,6 +34,7 @@ export { GlobalTab } from "./GlobalTab";
export { JailFilesTab } from "./JailFilesTab";
export { JailFileForm } from "./JailFileForm";
export { JailsTab } from "./JailsTab";
export { LogTab } from "./LogTab";
export { MapTab } from "./MapTab";
export { RawConfigSection } from "./RawConfigSection";
export type { RawConfigSectionProps } from "./RawConfigSection";

View File

@@ -22,6 +22,7 @@ import {
FiltersTab,
GlobalTab,
JailsTab,
LogTab,
MapTab,
RegexTesterTab,
ServerTab,
@@ -60,7 +61,8 @@ type TabValue =
| "global"
| "server"
| "map"
| "regex";
| "regex"
| "log";
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
@@ -91,6 +93,7 @@ export function ConfigPage(): React.JSX.Element {
<Tab value="server">Server</Tab>
<Tab value="map">Map</Tab>
<Tab value="regex">Regex Tester</Tab>
<Tab value="log">Log</Tab>
</TabList>
<div className={styles.tabContent} key={tab}>
@@ -101,6 +104,7 @@ export function ConfigPage(): React.JSX.Element {
{tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />}
{tab === "regex" && <RegexTesterTab />}
{tab === "log" && <LogTab />}
</div>
</div>
);

View File

@@ -592,3 +592,39 @@ export interface FilterCreateRequest {
export interface AssignFilterRequest {
filter_name: string;
}
// ---------------------------------------------------------------------------
// fail2ban log viewer types (Task 2)
// ---------------------------------------------------------------------------
/** Response for ``GET /api/config/fail2ban-log``. */
export interface Fail2BanLogResponse {
/** Resolved absolute path of the log file being read. */
log_path: string;
/** Log lines (tail of file, optionally filtered by substring). */
lines: string[];
/** Total number of lines in the file before any filtering. */
total_lines: number;
/** Current fail2ban log level, e.g. "INFO". */
log_level: string;
/** Current fail2ban log target (file path or special value like "STDOUT"). */
log_target: string;
}
/** Response for ``GET /api/config/service-status``. */
export interface ServiceStatusResponse {
/** Whether fail2ban is reachable via its socket. */
online: boolean;
/** fail2ban version string, or null when offline. */
version: string | null;
/** Number of currently active jails. */
jail_count: number;
/** Aggregated current ban count across all jails. */
total_bans: number;
/** Aggregated current failure count across all jails. */
total_failures: number;
/** Current fail2ban log level. */
log_level: string;
/** Current fail2ban log target. */
log_target: string;
}