12 Commits

Author SHA1 Message Date
670ff3e8a2 chore: release v0.9.13 2026-03-24 19:23:43 +01:00
f6672d0d16 better release script 2026-03-22 21:51:02 +01:00
d909f93efc chore: release v0.9.12 2026-03-22 21:47:40 +01:00
965cdd765b Fix useJailDetail mocked command responses to match JailCommandResponse type 2026-03-22 21:46:01 +01:00
0663740b08 chore: release v0.9.11 2026-03-22 21:43:31 +01:00
29587f2353 backup 2026-03-22 21:42:19 +01:00
798ed08ddd Refactor service status response: migrate bangui_version into version field 2026-03-22 21:42:08 +01:00
ed184f1c84 Fix config status and missing historical filter imports
1) Added _get_active_jail_names import in jail_config_service 2) Added _get_active_jail_names and _parse_jails_sync imports in filter_config_service and resolved constants/exceptions 3) Added bangui_version=__version__ in config_service.get_service_status and tests
2026-03-22 20:54:44 +01:00
8e1b4fa978 test: add regression test for 500 errors 2026-03-22 20:33:43 +01:00
e604e3aadf chore: commit changes from Copilot session 2026-03-22 20:33:06 +01:00
cf721513e8 Fix history origin filter path and add regression tests 2026-03-22 20:32:40 +01:00
a32cc82851 fix(blocklists): load useBlocklistStyles from blocklistStyles instead of commonStyles 2026-03-22 18:17:47 +01:00
27 changed files with 446 additions and 87 deletions

View File

@@ -1 +1 @@
v0.9.10
v0.9.13

View File

@@ -68,6 +68,13 @@ FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
echo "frontend/package.json version updated → ${FRONT_VERSION}"
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"
# ---------------------------------------------------------------------------
# Git tag (local only; push after container build)
# ---------------------------------------------------------------------------
@@ -77,12 +84,6 @@ git commit -m "chore: release ${NEW_TAG}"
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
echo "Local git commit + tag ${NEW_TAG} created."
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"
# ---------------------------------------------------------------------------
# Push git commits & tag
# ---------------------------------------------------------------------------

View File

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

View File

@@ -24,7 +24,6 @@ class ServerStatusResponse(BaseModel):
model_config = ConfigDict(strict=True)
status: ServerStatus
bangui_version: str = Field(..., description="BanGUI application version.")
class ServerSettings(BaseModel):

View File

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

View File

@@ -70,7 +70,8 @@ async def get_server_status(
"server_status",
ServerStatus(online=False),
)
return ServerStatusResponse(status=cached, bangui_version=__version__)
cached.version = __version__
return ServerStatusResponse(status=cached)
@router.get(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2001,8 +2001,7 @@ class TestGetServiceStatus:
def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
return ServiceStatusResponse(
online=online,
version="1.0.0" if online else None,
bangui_version=app.__version__,
version=app.__version__,
jail_count=2 if online else 0,
total_bans=10 if online else 0,
total_failures=3 if online else 0,
@@ -2021,7 +2020,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200
data = resp.json()
assert data["online"] is True
assert data["bangui_version"] == app.__version__
assert data["version"] == app.__version__
assert data["jail_count"] == 2
assert data["log_level"] == "INFO"
@@ -2035,7 +2034,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200
data = resp.json()
assert data["bangui_version"] == app.__version__
assert data["version"] == app.__version__
assert data["online"] is False
assert data["log_level"] == "UNKNOWN"

View File

@@ -153,8 +153,6 @@ class TestDashboardStatus:
body = response.json()
assert "status" in body
assert "bangui_version" in body
assert body["bangui_version"] == app.__version__
status = body["status"]
assert "online" in status
@@ -171,9 +169,8 @@ class TestDashboardStatus:
body = response.json()
status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is True
assert status["version"] == "1.0.2"
assert status["version"] == app.__version__
assert status["active_jails"] == 2
assert status["total_bans"] == 10
assert status["total_failures"] == 5
@@ -187,9 +184,8 @@ class TestDashboardStatus:
body = response.json()
status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is False
assert status["version"] is None
assert status["version"] == app.__version__
assert status["active_jails"] == 0
assert status["total_bans"] == 0
assert status["total_failures"] == 0

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
@@ -748,8 +749,10 @@ class TestGetServiceStatus:
probe_fn=AsyncMock(return_value=online_status),
)
from app import __version__
assert result.online is True
assert result.version == "1.0.0"
assert result.version == __version__
assert result.jail_count == 2
assert result.total_bans == 5
assert result.total_failures == 3
@@ -771,3 +774,62 @@ class TestGetServiceStatus:
assert result.jail_count == 0
assert result.log_level == "UNKNOWN"
assert result.log_target == "UNKNOWN"
@pytest.mark.asyncio
class TestConfigModuleIntegration:
async def test_jail_config_service_list_inactive_jails_uses_imports(self, tmp_path: Any) -> None:
from app.services.jail_config_service import list_inactive_jails
# Arrange: fake parse_jails output with one active and one inactive
def fake_parse_jails_sync(path: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
return (
{
"sshd": {
"enabled": "true",
"filter": "sshd",
"logpath": "/var/log/auth.log",
},
"apache-auth": {
"enabled": "false",
"filter": "apache-auth",
"logpath": "/var/log/apache2/error.log",
},
},
{
"sshd": str(path / "jail.conf"),
"apache-auth": str(path / "jail.conf"),
},
)
with patch(
"app.services.jail_config_service._parse_jails_sync",
new=fake_parse_jails_sync,
), patch(
"app.services.jail_config_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
names = {j.name for j in result.jails}
assert "apache-auth" in names
assert "sshd" not in names
async def test_filter_config_service_list_filters_uses_imports(self, tmp_path: Any) -> None:
from app.services.filter_config_service import list_filters
# Arrange minimal filter and jail config files
filter_d = tmp_path / "filter.d"
filter_d.mkdir(parents=True)
(filter_d / "sshd.conf").write_text("[Definition]\nfailregex = ^%(__prefix_line)s.*$\n")
(tmp_path / "jail.conf").write_text("[sshd]\nfilter = sshd\nenabled = true\n")
with patch(
"app.services.filter_config_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
):
result = await list_filters(str(tmp_path), "/fake.sock")
assert result.total == 1
assert result.filters[0].name == "sshd"
assert result.filters[0].active is True

View File

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

View File

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

View File

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

View File

@@ -70,7 +70,7 @@ const useStyles = makeStyles({
*/
export function ServerStatusBar(): React.JSX.Element {
const styles = useStyles();
const { status, banguiVersion, loading, error, refresh } = useServerStatus();
const { status, loading, error, refresh } = useServerStatus();
const cardStyles = useCardStyles();
@@ -98,21 +98,13 @@ export function ServerStatusBar(): React.JSX.Element {
{/* Version */}
{/* ---------------------------------------------------------------- */}
{status?.version != null && (
<Tooltip content="fail2ban daemon version" relationship="description">
<Tooltip content="BanGUI version" relationship="description">
<Text size={200} className={styles.statValue}>
v{status.version}
</Text>
</Tooltip>
)}
{banguiVersion != null && (
<Tooltip content="BanGUI version" relationship="description">
<Badge appearance="filled" size="small">
BanGUI v{banguiVersion}
</Badge>
</Tooltip>
)}
{/* ---------------------------------------------------------------- */}
{/* Stats (only when online) */}
{/* ---------------------------------------------------------------- */}

View File

@@ -41,7 +41,6 @@ describe("ServerStatusBar", () => {
it("shows a spinner while the initial load is in progress", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
banguiVersion: null,
loading: true,
error: null,
refresh: vi.fn(),
@@ -60,7 +59,6 @@ describe("ServerStatusBar", () => {
total_bans: 10,
total_failures: 5,
},
banguiVersion: "1.1.0",
loading: false,
error: null,
refresh: vi.fn(),
@@ -78,7 +76,6 @@ describe("ServerStatusBar", () => {
total_bans: 0,
total_failures: 0,
},
banguiVersion: "1.1.0",
loading: false,
error: null,
refresh: vi.fn(),
@@ -96,7 +93,6 @@ describe("ServerStatusBar", () => {
total_bans: 0,
total_failures: 0,
},
banguiVersion: "1.2.3",
loading: false,
error: null,
refresh: vi.fn(),
@@ -105,7 +101,7 @@ describe("ServerStatusBar", () => {
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
});
it("renders a BanGUI version badge", () => {
it("does not render a separate BanGUI version badge", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
@@ -114,13 +110,12 @@ describe("ServerStatusBar", () => {
total_bans: 0,
total_failures: 0,
},
banguiVersion: "9.9.9",
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("BanGUI v9.9.9")).toBeInTheDocument();
expect(screen.queryByText("BanGUI v9.9.9")).toBeNull();
});
it("does not render the version element when version is null", () => {
@@ -132,7 +127,6 @@ describe("ServerStatusBar", () => {
total_bans: 0,
total_failures: 0,
},
banguiVersion: "1.2.3",
loading: false,
error: null,
refresh: vi.fn(),
@@ -151,7 +145,6 @@ describe("ServerStatusBar", () => {
total_bans: 21,
total_failures: 99,
},
banguiVersion: "1.0.0",
loading: false,
error: null,
refresh: vi.fn(),
@@ -167,7 +160,6 @@ describe("ServerStatusBar", () => {
it("renders an error message when the status fetch fails", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
banguiVersion: null,
loading: false,
error: "Network error",
refresh: vi.fn(),

View File

@@ -352,12 +352,6 @@ export function ServerHealthSection(): React.JSX.Element {
<Text className={styles.statValue}>{status.version}</Text>
</div>
)}
{status.bangui_version && (
<div className={styles.statCard}>
<Text className={styles.statLabel}>BanGUI</Text>
<Text className={styles.statValue}>{status.bangui_version}</Text>
</div>
)}
<div className={styles.statCard}>
<Text className={styles.statLabel}>Active Jails</Text>
<Text className={styles.statValue}>{status.jail_count}</Text>

View File

@@ -15,11 +15,10 @@ describe("ServerHealthSection", () => {
vi.clearAllMocks();
});
it("shows the BanGUI version in the service health panel", async () => {
it("shows the version in the service health panel", async () => {
mockedFetchServiceStatus.mockResolvedValue({
online: true,
version: "1.2.3",
bangui_version: "1.2.3",
jail_count: 2,
total_bans: 5,
total_failures: 1,
@@ -41,11 +40,11 @@ describe("ServerHealthSection", () => {
</FluentProvider>,
);
// The service health panel should render and include the BanGUI version.
const banGuiLabel = await screen.findByText("BanGUI");
expect(banGuiLabel).toBeInTheDocument();
// The service health panel should render and include the version.
const versionLabel = await screen.findByText("Version");
expect(versionLabel).toBeInTheDocument();
const banGuiCard = banGuiLabel.closest("div");
expect(banGuiCard).toHaveTextContent("1.2.3");
const versionCard = versionLabel.closest("div");
expect(versionCard).toHaveTextContent("1.2.3");
});
});

View File

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

View File

@@ -18,8 +18,6 @@ const POLL_INTERVAL_MS = 30_000;
export interface UseServerStatusResult {
/** The most recent server status snapshot, or `null` before the first fetch. */
status: ServerStatus | null;
/** BanGUI application version string. */
banguiVersion: string | null;
/** Whether a fetch is currently in flight. */
loading: boolean;
/** Error message string when the last fetch failed, otherwise `null`. */
@@ -35,7 +33,6 @@ export interface UseServerStatusResult {
*/
export function useServerStatus(): UseServerStatusResult {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [banguiVersion, setBanguiVersion] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -47,7 +44,6 @@ export function useServerStatus(): UseServerStatusResult {
try {
const data = await fetchServerStatus();
setStatus(data.status);
setBanguiVersion(data.bangui_version);
setError(null);
} catch (err: unknown) {
handleFetchError(err, setError, "Failed to fetch server status");
@@ -82,5 +78,5 @@ export function useServerStatus(): UseServerStatusResult {
void doFetch().catch((): void => undefined);
}, [doFetch]);
return { status, banguiVersion, loading, error, refresh };
return { status, loading, error, refresh };
}

View File

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

View File

@@ -659,10 +659,8 @@ export interface Fail2BanLogResponse {
export interface ServiceStatusResponse {
/** Whether fail2ban is reachable via its socket. */
online: boolean;
/** fail2ban version string, or null when offline. */
/** BanGUI application version (or null when offline). */
version: string | null;
/** BanGUI application version (from the API). */
bangui_version: string;
/** Number of currently active jails. */
jail_count: number;
/** Aggregated current ban count across all jails. */

View File

@@ -21,6 +21,4 @@ export interface ServerStatus {
/** Response shape for ``GET /api/dashboard/status``. */
export interface ServerStatusResponse {
status: ServerStatus;
/** BanGUI application version (from the API). */
bangui_version: string;
}