8 Commits

28 changed files with 524 additions and 88 deletions

View File

@@ -1 +1 @@
v0.9.8 v0.9.10

View File

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

View File

@@ -12,81 +12,211 @@ This document breaks the entire BanGUI project into development stages, ordered
--- ---
### Task 1 — Blocklist-import jail ban time must be 24 hours ## Feature: Worldmap Country Tooltip
**Status:** ✅ Done > **2026-03-17**
> The world map on the Map page colours each country by ban count but provides no immediate information on hover — the user must click a country to see its name in the filter bar below, and must read the small SVG count label to learn the number of bans.
**Context** >
> Goal: show a lightweight floating tooltip whenever the pointer enters a country, displaying the country's display name and its current ban count, so the information is accessible without a click.
When the blocklist importer bans an IP it calls `jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, ip)` (see `backend/app/services/blocklist_service.py`, constant `BLOCKLIST_JAIL = "blocklist-import"`). That call sends `set blocklist-import banip <ip>` to fail2ban, which applies the jail's configured `bantime`. There is currently no guarantee that the `blocklist-import` jail's `bantime` is 86 400 s (24 h), so imported IPs may be released too early or held indefinitely depending on the jail template.
**What to do**
1. Locate every place the `blocklist-import` jail is defined or provisioned — check `Docker/fail2ban-dev-config/`, `Docker/Dockerfile.backend`, any jail template files, and the `setup_service.py` / `SetupPage.tsx` flow.
2. Ensure the `blocklist-import` jail is created with `bantime = 86400` (24 h). If the jail is created at runtime by the setup service, add or update the `bantime` parameter there. If it is defined in a static config file, set `bantime = 86400` in that file.
3. Verify that the existing `jail_service.ban_ip` call in `blocklist_service.import_source` does not need a per-call duration override; the jail-level default of 86 400 s is sufficient.
4. Add or update the relevant unit/integration test in `backend/tests/` to assert that the blocklist-import jail is set up with a 24-hour bantime.
--- ---
### Task 2 — Clicking a jail in Jail Overview navigates to Configuration → Jails ### Task WM-1 — Show country name and ban count tooltip on map hover
**Status:** ✅ Done **Scope:** `frontend/src/components/WorldMap.tsx`, `frontend/src/pages/MapPage.tsx`
**Context** `countryNames` (ISO alpha-2 → display name) is already available in `MapPage` from `useMapData` but is not forwarded to `WorldMap`. The map component itself tracks no hover state. This task adds pointer-event handlers to each country `<g>` element, tracks the hovered country in local state together with the last known mouse coordinates, and renders a positionned HTML tooltip `<div>` on top of the SVG.
`JailsPage.tsx` renders a "Jail Overview" data grid with one row per jail (see `frontend/src/pages/JailsPage.tsx`). Clicking a row currently does nothing. `ConfigPage.tsx` hosts a tab bar with a "Jails" tab that renders `JailsTab`, which already uses a list/detail layout where a jail can be selected from the left pane. **Implementation steps:**
**What to do** 1. **Extend `WorldMapProps` and `GeoLayerProps`** in `WorldMap.tsx`:
- Add `countryNames?: Record<string, string>` to `WorldMapProps` (optional — falls back to the ISO alpha-2 code when absent).
- Thread it through `GeoLayer` the same way the threshold props are already threaded.
1. In `JailsPage.tsx`, make each jail name cell (or the entire row) a clickable element that navigates to `/config` with state `{ tab: "jails", jail: "<jailName>" }`. Use `useNavigate` from `react-router-dom`; the existing `Link` import can be used or replaced with a programmatic navigate. 2. **Add hover state to `GeoLayer`** — declare:
2. In `ConfigPage.tsx`, read the location state on mount. If `state.tab` is `"jails"`, set the active tab to `"jails"`. Pass `state.jail` down to `<JailsTab initialJail={state.jail} />`. ```ts
3. In `JailsTab.tsx`, accept an optional `initialJail?: string` prop. When it is provided, pre-select that jail in the left-pane list on first render (i.e. set the selected jail state to the jail whose name matches `initialJail`). This should scroll the item into view if the list is long. const [tooltip, setTooltip] = useState<{
4. Add a frontend unit test in `frontend/src/pages/__tests__/` that mounts `JailsPage` with a mocked jail list, clicks a jail row, and asserts that `useNavigate` was called with the correct path and state. cc: string;
count: number;
name: string;
x: number;
y: number;
} | null>(null);
```
On each country `<g>` element add:
- `onMouseEnter` — set `tooltip` with the country code, count, display name (from `countryNames`, falling back to the alpha-2 code), and mouse page coordinates (`e.clientX`, `e.clientY`).
- `onMouseMove` — update only the `x`/`y` in the existing tooltip (keep name/count stable).
- `onMouseLeave` — set `tooltip` to `null`.
Skip setting the tooltip for countries where `cc === null` (no ISO mapping available) but keep `onMouseLeave` so re-entering after leaving from an unmapped border still clears the state.
3. **Render the tooltip inside `GeoLayer`** — because `GeoLayer` is rendered inside `ComposableMap` which is inside `mapWrapper`, the tooltip div cannot be positioned relative to the map wrapper from here (the SVG clip/transform would offset it). Instead, use a React **portal** (`ReactDOM.createPortal`) to mount the tooltip directly on `document.body` so it sits in the root stacking context and can be positioned with `position: fixed` using the raw `clientX`/`clientY` coordinates.
Tooltip structure (styled with a new `makeStyles` class `tooltip` in `WorldMap.tsx`):
```tsx
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
```
4. **Tooltip styles** — add three new classes to the `makeStyles` call in `WorldMap.tsx`:
```ts
tooltip: {
position: "fixed",
zIndex: 9999,
pointerEvents: "none",
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
borderRadius: tokens.borderRadiusSmall,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXXS,
boxShadow: tokens.shadow4,
},
tooltipCountry: {
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1,
},
tooltipCount: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground2,
},
```
5. **Pass `countryNames` from `MapPage`** — in `MapPage.tsx`, add the `countryNames` prop to the existing `<WorldMap …>` JSX:
```tsx
<WorldMap
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry}
/>
```
6. **Countries with zero bans** — the tooltip should still appear when the user hovers over a country with `0` bans (showing the name and "0 bans"), so users know the country is tracked but has no bans. Do not suppress the tooltip for zero-count countries.
**Acceptance criteria:**
- Moving the pointer over any mapped country on the Map page shows a floating tooltip within 0 ms (synchronous state update) containing the country's full display name (e.g. `Germany`) on the first line and the ban count (e.g. `42 bans` or `0 bans`) on the second line.
- Moving the pointer off a country hides the tooltip immediately.
- The tooltip follows the pointer as it moves within a country's borders.
- Clicking a country still selects/deselects it exactly as before; the tooltip does not interfere with the click handler.
- The tooltip is not interactive (`pointerEvents: none`) and does not steal focus from the map.
- `tsc --noEmit` produces no new errors.
**Status:** ✅ Completed (2026-03-19)
--- ---
### Task 3 — Setting bantime / findtime throws 400 error due to unsupported `backend` set command ## Feature: Global Unique BanGUI Version
**Status:** ✅ Done > **2026-03-17**
> The BanGUI application version is currently scattered across three independent files that are not kept in sync:
**Context** > - `Docker/VERSION` — `v0.9.8` (release artifact, written by the release script)
> - `frontend/package.json` — `0.9.8`
Editing ban time or find time in Configuration → Jails triggers an auto-save that sends the full `JailConfigUpdate` payload including the `backend` field. `config_service.update_jail_config` then calls `set <jail> backend <value>` on the fail2ban socket, which returns error code 1 with the message `Invalid command 'backend' (no set action or not yet implemented)`. Fail2ban does not support changing a jail's backend at runtime; it must be set before the jail starts. > - `backend/pyproject.toml` — `0.9.4` ← **out of sync**
>
**What to do** > Additionally the BanGUI version is only shown in the sidebar footer (`MainLayout.tsx`). Neither the Dashboard nor the Configuration → Server view exposes the BanGUI application version, only the fail2ban daemon version.
>
**Backend** (`backend/app/services/config_service.py`): > Goal: one authoritative version string, propagated automatically to all layers, and displayed consistently on both the Dashboard and the Configuration → Server page.
1. Remove the `if update.backend is not None: await _set("backend", update.backend)` block from `update_jail_config`. Setting `backend` via the socket is not supported by fail2ban and will always fail.
2. `log_encoding` has the same constraint — verify whether `set <jail> logencoding` is supported at runtime. If it is not, remove it too. If it is supported, leave it.
3. Ensure the function still accepts and stores the `backend` value in the Pydantic model for read purposes; do not remove it from `JailConfigUpdate` or the response model.
**Frontend** (`frontend/src/components/config/JailsTab.tsx`):
4. Remove `backend` (and `log_encoding` if step 2 confirms it is unsupported) from the `autoSavePayload` memo so the field is never sent in the PATCH/PUT body. The displayed value should remain read-only — show them as plain text or a disabled select so the user can see the current value without being able to trigger the broken set command.
**Tests**:
5. Add or update the backend test for `update_jail_config` to assert that no `set … backend` command is issued, and that a payload containing a `backend` field does not cause an error.
--- ---
### Task 4 — Unify filter bar: use `DashboardFilterBar` in World Map and History pages ### Task GV-1 — Establish a single source of truth for the BanGUI version
**Status:** ✅ Done **Scope:** `Docker/VERSION`, `backend/pyproject.toml`, `frontend/package.json`, `backend/app/__init__.py`
**Context** `Docker/VERSION` is already the file written by the release script (`Docker/release.sh`) and is therefore the natural single source of truth.
`DashboardPage.tsx` uses the shared `<DashboardFilterBar>` component for its time-range and origin-filter controls. `MapPage.tsx` and `HistoryPage.tsx` each implement their own ad-hoc filter UI: `MapPage` uses a Fluent UI `<Select>` for time range plus an inline Toolbar for origin filter; `HistoryPage` uses a `<Select>` for time range with no origin filter toggle. The `DashboardFilterBar` already supports both `TimeRange` and `BanOriginFilter` with the exact toggle-button style shown in the design reference. All three pages should share the same filter appearance and interaction patterns. 1. Sync the two package manifests to the current release version:
- Set `version` in `backend/pyproject.toml` to `0.9.8` (strip the leading `v` that `Docker/VERSION` contains).
- `frontend/package.json` is already `0.9.8` — no change needed.
2. Make the backend read its version **directly from `Docker/VERSION`** at import time instead of from `pyproject.toml`, so a future release-script bump of `Docker/VERSION` is sufficient. Update `_read_pyproject_version()` in `backend/app/__init__.py`:
- Add a new helper `_read_docker_version() -> str` that resolves `Docker/VERSION` relative to the repository root (two `parents` above `backend/app/`), strips the leading `v` and whitespace, and returns the bare semver string.
- Change `_read_version()` to try `_read_docker_version()` first, then fall back to `_read_pyproject_version()`, then `importlib.metadata`.
3. Make the frontend read its version from `Docker/VERSION` at build time. In `frontend/vite.config.ts`, replace the `pkg.version` import with a `fs.readFileSync('../Docker/VERSION', 'utf-8').trim().replace(/^v/, '')` call so both the dev server and production build always reflect the file.
- Update `declare const __APP_VERSION__: string;` in `frontend/src/vite-env.d.ts` if the type declaration needs adjustment (it should not).
**What to do** **Acceptance criteria:**
- `backend/app/__version__` equals the content of `Docker/VERSION` (without `v` prefix) at runtime.
- `frontend` build constant `__APP_VERSION__` equals the same value.
- Bumping only `Docker/VERSION` (e.g. `v0.9.9`) causes both layers to pick up the new version without touching any other file.
- All existing tests pass (`pytest backend/`).
1. **`MapPage.tsx`**: Replace the custom time-range `<Select>` and the inline origin-filter Toolbar with `<DashboardFilterBar timeRange={range} onTimeRangeChange={setRange} originFilter={originFilter} onOriginFilterChange={setOriginFilter} />`. Remove the now-unused `TIME_RANGE_OPTIONS` constant and the `BAN_ORIGIN_FILTER_LABELS` inline usage. Pass `originFilter` to `useMapData` if it does not already receive it (check the hook signature). **Status:** ✅ Completed (2026-03-19)
2. **`HistoryPage.tsx`**: Replace the custom time-range `<Select>` with `<DashboardFilterBar>`. Add an `originFilter` state (`BanOriginFilter`, default `"all"`) and wire it through `<DashboardFilterBar onOriginFilterChange={setOriginFilter} />`. Pass the origin filter into the `useHistory` query so the backend receives it. If `useHistory` / `HistoryQuery` does not yet accept `origin_filter`, add the parameter to the type and the hook's fetch call.
3. Remove any local `filterBar` style definitions from `MapPage.tsx` and `HistoryPage.tsx` that duplicate what `DashboardFilterBar` already provides.
4. Ensure the `DashboardFilterBar` component's props interface (`DashboardFilterBarProps` in `frontend/src/components/DashboardFilterBar.tsx`) is not changed in a breaking way; only the call sites change.
5. Update or add component tests for `MapPage` and `HistoryPage` to assert that `DashboardFilterBar` is rendered and that changing the time range or origin filter updates the displayed data.
--- ---
### Task GV-2 — Expose the BanGUI version through the API
**Scope:** `backend/app/models/server.py`, `backend/app/models/config.py`, `backend/app/routers/dashboard.py`, `backend/app/routers/config.py`
Add a `bangui_version` field to every API response that already carries the fail2ban daemon `version`, so the frontend can display the BanGUI application version next to it.
1. **`backend/app/models/server.py`** — Add to `ServerStatusResponse`:
```python
bangui_version: str = Field(..., description="BanGUI application version.")
```
2. **`backend/app/models/config.py`** — Add to `ServiceStatusResponse`:
```python
bangui_version: str = Field(..., description="BanGUI application version.")
```
3. **`backend/app/routers/dashboard.py`** — In `get_server_status`, import `__version__` from `app` and populate the new field:
```python
return ServerStatusResponse(status=cached, bangui_version=__version__)
```
4. **`backend/app/routers/config.py`** — Do the same for the `GET /api/config/service-status` endpoint.
**Do not** change the existing `version` field (fail2ban daemon version) — keep it exactly as-is so nothing downstream breaks.
**Acceptance criteria:**
- `GET /api/dashboard/status` response JSON contains `"bangui_version": "0.9.8"`.
- `GET /api/config/service-status` response JSON contains `"bangui_version": "0.9.8"`.
- All existing backend tests pass.
- Add one test per endpoint asserting that `bangui_version` matches `app.__version__`.
**Status:** ✅ Completed (2026-03-19)
---
### Task GV-3 — Display the BanGUI version on Dashboard and Configuration → Server
**Scope:** `frontend/src/components/ServerStatusBar.tsx`, `frontend/src/components/config/ServerHealthSection.tsx`, `frontend/src/types/server.ts`, `frontend/src/types/config.ts`
After GV-2 the API delivers `bangui_version`; this task makes the frontend show it.
1. **Type definitions**
- `frontend/src/types/server.ts` — Add `bangui_version: string` to the `ServerStatusResponse` interface.
- `frontend/src/types/config.ts` — Add `bangui_version: string` to the `ServiceStatusResponse` interface.
2. **Dashboard — `ServerStatusBar.tsx`**
The status bar already renders `v{status.version}` (fail2ban version with a tooltip). Add a second badge directly adjacent to it that reads `BanGUI v{status.bangui_version}` with the tooltip `"BanGUI version"`. Match the existing badge style.
3. **Configuration → Server — `ServerHealthSection.tsx`**
The health section already renders a `Version` row with the fail2ban version. Add a new row below it labelled `BanGUI` (or `BanGUI Version`) that renders `{status.bangui_version}`. Apply the same `statLabel` / `statValue` CSS classes used by the adjacent rows.
4. **Remove the duplicate from the sidebar** — Once the version is visible on the relevant pages, the sidebar footer in `frontend/src/layouts/MainLayout.tsx` can drop `v{__APP_VERSION__}` to avoid showing the version in three places. Replace it with the plain product name `BanGUI` — **only do this if the design document (`Docs/Web-Design.md`) does not mandate showing the version there**; otherwise leave it and note the decision in a comment.
**Acceptance criteria:**
- Dashboard status bar shows `BanGUI v0.9.8` with an appropriate tooltip.
- Configuration → Server health section shows a `BanGUI` version row reading `0.9.8`.
- No TypeScript compile errors (`tsc --noEmit`).
- Both values originate from the same API field (`bangui_version`) and therefore always match the backend version.
**Status:** ✅ Completed (2026-03-19)
---

View File

@@ -30,21 +30,39 @@ def _read_pyproject_version() -> str:
return str(data["project"]["version"]) return str(data["project"]["version"])
def _read_docker_version() -> str:
"""Read the project version from ``Docker/VERSION``.
This file is the single source of truth for release scripts and must not be
out of sync with the frontend and backend versions.
"""
repo_root = Path(__file__).resolve().parents[2]
version_path = repo_root / "Docker" / "VERSION"
if not version_path.exists():
raise FileNotFoundError(f"Docker/VERSION not found at {version_path}")
version = version_path.read_text(encoding="utf-8").strip()
return version.lstrip("v")
def _read_version() -> str: def _read_version() -> str:
"""Return the current package version. """Return the current package version.
Prefer the project metadata in ``pyproject.toml`` when available, since this Prefer the release artifact in ``Docker/VERSION`` when available so the
is the single source of truth for local development and is kept in sync with backend version always matches what the release tooling publishes.
the frontend and Docker release version.
When running from an installed distribution where the ``pyproject.toml`` If that file is missing (e.g. in a production wheel or a local checkout),
is not available, fall back to installed package metadata. fall back to ``pyproject.toml`` and finally installed package metadata.
""" """
try: try:
return _read_pyproject_version() return _read_docker_version()
except FileNotFoundError: except FileNotFoundError:
return importlib.metadata.version(PACKAGE_NAME) try:
return _read_pyproject_version()
except FileNotFoundError:
return importlib.metadata.version(PACKAGE_NAME)
__version__ = _read_version() __version__ = _read_version()

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ if TYPE_CHECKING:
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, Query, Request
from app import __version__
from app.dependencies import AuthDep from app.dependencies import AuthDep
from app.models.ban import ( from app.models.ban import (
BanOrigin, BanOrigin,
@@ -69,7 +70,7 @@ async def get_server_status(
"server_status", "server_status",
ServerStatus(online=False), ServerStatus(online=False),
) )
return ServerStatusResponse(status=cached) return ServerStatusResponse(status=cached, bangui_version=__version__)
@router.get( @router.get(

View File

@@ -897,6 +897,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
Returns: Returns:
:class:`~app.models.config.ServiceStatusResponse`. :class:`~app.models.config.ServiceStatusResponse`.
""" """
from app import __version__ # noqa: TCH001 - expose the app release version
from app.services.health_service import probe # lazy import avoids circular dep from app.services.health_service import probe # lazy import avoids circular dep
server_status = await probe(socket_path) server_status = await probe(socket_path)
@@ -922,6 +923,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
return ServiceStatusResponse( return ServiceStatusResponse(
online=server_status.online, online=server_status.online,
version=server_status.version, version=server_status.version,
bangui_version=__version__,
jail_count=server_status.active_jails, jail_count=server_status.active_jails,
total_bans=server_status.total_bans, total_bans=server_status.total_bans,
total_failures=server_status.total_failures, total_failures=server_status.total_failures,

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "bangui-backend" name = "bangui-backend"
version = "0.9.4" version = "0.9.8"
description = "BanGUI backend — fail2ban web management interface" description = "BanGUI backend — fail2ban web management interface"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [

View File

@@ -9,6 +9,8 @@ import aiosqlite
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
import app
from app.config import Settings from app.config import Settings
from app.db import init_db from app.db import init_db
from app.main import create_app from app.main import create_app
@@ -2000,6 +2002,7 @@ class TestGetServiceStatus:
return ServiceStatusResponse( return ServiceStatusResponse(
online=online, online=online,
version="1.0.0" if online else None, version="1.0.0" if online else None,
bangui_version=app.__version__,
jail_count=2 if online else 0, jail_count=2 if online else 0,
total_bans=10 if online else 0, total_bans=10 if online else 0,
total_failures=3 if online else 0, total_failures=3 if online else 0,
@@ -2018,6 +2021,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["online"] is True assert data["online"] is True
assert data["bangui_version"] == app.__version__
assert data["jail_count"] == 2 assert data["jail_count"] == 2
assert data["log_level"] == "INFO" assert data["log_level"] == "INFO"
@@ -2031,6 +2035,7 @@ class TestGetServiceStatus:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["bangui_version"] == app.__version__
assert data["online"] is False assert data["online"] is False
assert data["log_level"] == "UNKNOWN" assert data["log_level"] == "UNKNOWN"

View File

@@ -9,6 +9,8 @@ import aiosqlite
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
import app
from app.config import Settings from app.config import Settings
from app.db import init_db from app.db import init_db
from app.main import create_app from app.main import create_app
@@ -151,6 +153,9 @@ class TestDashboardStatus:
body = response.json() body = response.json()
assert "status" in body assert "status" in body
assert "bangui_version" in body
assert body["bangui_version"] == app.__version__
status = body["status"] status = body["status"]
assert "online" in status assert "online" in status
assert "version" in status assert "version" in status
@@ -163,8 +168,10 @@ class TestDashboardStatus:
) -> None: ) -> None:
"""Endpoint returns the exact values from ``app.state.server_status``.""" """Endpoint returns the exact values from ``app.state.server_status``."""
response = await dashboard_client.get("/api/dashboard/status") response = await dashboard_client.get("/api/dashboard/status")
status = response.json()["status"] body = response.json()
status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is True assert status["online"] is True
assert status["version"] == "1.0.2" assert status["version"] == "1.0.2"
assert status["active_jails"] == 2 assert status["active_jails"] == 2
@@ -177,8 +184,10 @@ class TestDashboardStatus:
"""Endpoint returns online=False when the cache holds an offline snapshot.""" """Endpoint returns online=False when the cache holds an offline snapshot."""
response = await offline_dashboard_client.get("/api/dashboard/status") response = await offline_dashboard_client.get("/api/dashboard/status")
assert response.status_code == 200 assert response.status_code == 200
status = response.json()["status"] body = response.json()
status = body["status"]
assert body["bangui_version"] == app.__version__
assert status["online"] is False assert status["online"] is False
assert status["version"] is None assert status["version"] is None
assert status["active_jails"] == 0 assert status["active_jails"] == 0

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from pathlib import Path
import app
def test_app_version_matches_docker_version() -> None:
"""The backend version should match the signed off Docker release version."""
repo_root = Path(__file__).resolve().parents[2]
version_file = repo_root / "Docker" / "VERSION"
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
assert app.__version__ == expected

View File

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

View File

@@ -83,7 +83,7 @@ const useStyles = makeStyles({
*/ */
export function ServerStatusBar(): React.JSX.Element { export function ServerStatusBar(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const { status, loading, error, refresh } = useServerStatus(); const { status, banguiVersion, loading, error, refresh } = useServerStatus();
return ( return (
<div className={styles.bar} role="status" aria-label="fail2ban server status"> <div className={styles.bar} role="status" aria-label="fail2ban server status">
@@ -116,6 +116,14 @@ export function ServerStatusBar(): React.JSX.Element {
</Tooltip> </Tooltip>
)} )}
{banguiVersion != null && (
<Tooltip content="BanGUI version" relationship="description">
<Badge appearance="filled" size="small">
BanGUI v{banguiVersion}
</Badge>
</Tooltip>
)}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
{/* Stats (only when online) */} {/* Stats (only when online) */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -7,6 +7,7 @@
* country filters the companion table. * country filters the companion table.
*/ */
import { createPortal } from "react-dom";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
import { Button, makeStyles, tokens } from "@fluentui/react-components"; import { Button, makeStyles, tokens } from "@fluentui/react-components";
@@ -50,6 +51,28 @@ const useStyles = makeStyles({
gap: tokens.spacingVerticalXS, gap: tokens.spacingVerticalXS,
zIndex: 10, zIndex: 10,
}, },
tooltip: {
position: "fixed",
zIndex: 9999,
pointerEvents: "none",
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
borderRadius: tokens.borderRadiusSmall,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXXS,
boxShadow: tokens.shadow4,
},
tooltipCountry: {
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground1,
},
tooltipCount: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground2,
},
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -58,6 +81,7 @@ const useStyles = makeStyles({
interface GeoLayerProps { interface GeoLayerProps {
countries: Record<string, number>; countries: Record<string, number>;
countryNames?: Record<string, string>;
selectedCountry: string | null; selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void; onSelectCountry: (cc: string | null) => void;
thresholdLow: number; thresholdLow: number;
@@ -67,6 +91,7 @@ interface GeoLayerProps {
function GeoLayer({ function GeoLayer({
countries, countries,
countryNames,
selectedCountry, selectedCountry,
onSelectCountry, onSelectCountry,
thresholdLow, thresholdLow,
@@ -76,6 +101,17 @@ function GeoLayer({
const styles = useStyles(); const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL }); const { geographies, path } = useGeographies({ geography: GEO_URL });
const [tooltip, setTooltip] = useState<
| {
cc: string;
count: number;
name: string;
x: number;
y: number;
}
| null
>(null);
const handleClick = useCallback( const handleClick = useCallback(
(cc: string | null): void => { (cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc); onSelectCountry(selectedCountry === cc ? null : cc);
@@ -136,6 +172,30 @@ function GeoLayer({
handleClick(cc); handleClick(cc);
} }
}} }}
onMouseEnter={(e): void => {
if (!cc) return;
setTooltip({
cc,
count,
name: countryNames?.[cc] ?? cc,
x: e.clientX,
y: e.clientY,
});
}}
onMouseMove={(e): void => {
setTooltip((current) =>
current
? {
...current,
x: e.clientX,
y: e.clientY,
}
: current,
);
}}
onMouseLeave={(): void => {
setTooltip(null);
}}
> >
<Geography <Geography
geography={geo} geography={geo}
@@ -179,6 +239,22 @@ function GeoLayer({
); );
}, },
)} )}
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
</> </>
); );
} }
@@ -190,6 +266,8 @@ function GeoLayer({
export interface WorldMapProps { export interface WorldMapProps {
/** ISO alpha-2 country code → ban count. */ /** ISO alpha-2 country code → ban count. */
countries: Record<string, number>; countries: Record<string, number>;
/** Optional mapping from country code to display name. */
countryNames?: Record<string, string>;
/** Currently selected country filter (null means no filter). */ /** Currently selected country filter (null means no filter). */
selectedCountry: string | null; selectedCountry: string | null;
/** Called when the user clicks a country or deselects. */ /** Called when the user clicks a country or deselects. */
@@ -204,6 +282,7 @@ export interface WorldMapProps {
export function WorldMap({ export function WorldMap({
countries, countries,
countryNames,
selectedCountry, selectedCountry,
onSelectCountry, onSelectCountry,
thresholdLow = 20, thresholdLow = 20,
@@ -286,6 +365,7 @@ export function WorldMap({
> >
<GeoLayer <GeoLayer
countries={countries} countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry} selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry} onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow} thresholdLow={thresholdLow}

View File

@@ -41,6 +41,7 @@ describe("ServerStatusBar", () => {
it("shows a spinner while the initial load is in progress", () => { it("shows a spinner while the initial load is in progress", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: null, status: null,
banguiVersion: null,
loading: true, loading: true,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -59,6 +60,7 @@ describe("ServerStatusBar", () => {
total_bans: 10, total_bans: 10,
total_failures: 5, total_failures: 5,
}, },
banguiVersion: "1.1.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -76,6 +78,7 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.1.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -93,6 +96,7 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.2.3",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -101,6 +105,24 @@ describe("ServerStatusBar", () => {
expect(screen.getByText("v1.2.3")).toBeInTheDocument(); expect(screen.getByText("v1.2.3")).toBeInTheDocument();
}); });
it("renders a BanGUI version badge", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.2.3",
active_jails: 1,
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();
});
it("does not render the version element when version is null", () => { it("does not render the version element when version is null", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: { status: {
@@ -110,6 +132,7 @@ describe("ServerStatusBar", () => {
total_bans: 0, total_bans: 0,
total_failures: 0, total_failures: 0,
}, },
banguiVersion: "1.2.3",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -128,6 +151,7 @@ describe("ServerStatusBar", () => {
total_bans: 21, total_bans: 21,
total_failures: 99, total_failures: 99,
}, },
banguiVersion: "1.0.0",
loading: false, loading: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
@@ -143,6 +167,7 @@ describe("ServerStatusBar", () => {
it("renders an error message when the status fetch fails", () => { it("renders an error message when the status fetch fails", () => {
mockedUseServerStatus.mockReturnValue({ mockedUseServerStatus.mockReturnValue({
status: null, status: null,
banguiVersion: null,
loading: false, loading: false,
error: "Network error", error: "Network error",
refresh: vi.fn(), refresh: vi.fn(),

View File

@@ -0,0 +1,51 @@
/**
* Tests for WorldMap component.
*
* Verifies that hovering a country shows a tooltip with the country name and ban count.
*/
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
vi.mock("react-simple-maps", () => ({
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Geography: ({ children }: { children?: React.ReactNode }) => <g>{children}</g>,
useGeographies: () => ({
geographies: [{ rsmKey: "geo-1", id: 840 }],
path: { centroid: () => [10, 10] },
}),
}));
import { WorldMap } from "../WorldMap";
describe("WorldMap", () => {
it("shows a tooltip with country name and ban count on hover", () => {
render(
<FluentProvider theme={webLightTheme}>
<WorldMap
countries={{ US: 42 }}
countryNames={{ US: "United States" }}
selectedCountry={null}
onSelectCountry={vi.fn()}
/>
</FluentProvider>,
);
// Tooltip should not be present initially
expect(screen.queryByRole("tooltip")).toBeNull();
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i });
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("United States");
expect(tooltip).toHaveTextContent("42 bans");
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
fireEvent.mouseLeave(countryButton);
expect(screen.queryByRole("tooltip")).toBeNull();
});
});

View File

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

View File

@@ -61,7 +61,14 @@ describe("JailsTab", () => {
reloadAll: vi.fn(), reloadAll: vi.fn(),
}); });
mockUseConfigActiveStatus.mockReturnValue({ activeJails: new Set<string>() }); mockUseConfigActiveStatus.mockReturnValue({
activeJails: new Set<string>(),
activeFilters: new Set<string>(),
activeActions: new Set<string>(),
loading: false,
error: null,
refresh: vi.fn(),
});
render( render(
<FluentProvider theme={webLightTheme}> <FluentProvider theme={webLightTheme}>

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ServerHealthSection } from "../ServerHealthSection";
vi.mock("../../../api/config");
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
const mockedFetchServiceStatus = vi.mocked(fetchServiceStatus);
const mockedFetchFail2BanLog = vi.mocked(fetchFail2BanLog);
describe("ServerHealthSection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the BanGUI 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,
log_level: "INFO",
log_target: "STDOUT",
});
mockedFetchFail2BanLog.mockResolvedValue({
log_path: "/var/log/fail2ban.log",
lines: ["2026-01-01 fail2ban[123]: INFO Test"],
total_lines: 1,
log_level: "INFO",
log_target: "STDOUT",
});
render(
<FluentProvider theme={webLightTheme}>
<ServerHealthSection />
</FluentProvider>,
);
// The service health panel should render and include the BanGUI version.
const banGuiLabel = await screen.findByText("BanGUI");
expect(banGuiLabel).toBeInTheDocument();
const banGuiCard = banGuiLabel.closest("div");
expect(banGuiCard).toHaveTextContent("1.2.3");
});
});

View File

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

View File

@@ -313,7 +313,7 @@ export function MainLayout(): React.JSX.Element {
<div className={styles.sidebarFooter}> <div className={styles.sidebarFooter}>
{!collapsed && ( {!collapsed && (
<Text className={styles.versionText}> <Text className={styles.versionText}>
BanGUI v{__APP_VERSION__} BanGUI
</Text> </Text>
)} )}
<Tooltip <Tooltip

View File

@@ -63,16 +63,16 @@ describe("MainLayout", () => {
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
}); });
it("shows the BanGUI version in the sidebar footer when expanded", () => { it("does not show the BanGUI application version in the sidebar footer", () => {
renderLayout(); renderLayout();
// __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define. // __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define.
expect(screen.getByText("BanGUI v0.0.0-test")).toBeInTheDocument(); expect(screen.queryByText(/BanGUI v/)).not.toBeInTheDocument();
}); });
it("hides the BanGUI version text when the sidebar is collapsed", async () => { it("hides the logo text when the sidebar is collapsed", async () => {
renderLayout(); renderLayout();
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i }); const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
await userEvent.click(toggleButton); await userEvent.click(toggleButton);
expect(screen.queryByText("BanGUI v0.0.0-test")).not.toBeInTheDocument(); expect(screen.queryByText("BanGUI")).not.toBeInTheDocument();
}); });
}); });

View File

@@ -167,6 +167,7 @@ export function MapPage(): React.JSX.Element {
{!loading && !error && ( {!loading && !error && (
<WorldMap <WorldMap
countries={countries} countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry} selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry} onSelectCountry={setSelectedCountry}
thresholdLow={thresholdLow} thresholdLow={thresholdLow}

View File

@@ -32,8 +32,12 @@ vi.mock("../api/config", async () => ({
fetchMapColorThresholds: mockFetchMapColorThresholds, fetchMapColorThresholds: mockFetchMapColorThresholds,
})); }));
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
vi.mock("../components/WorldMap", () => ({ vi.mock("../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />, WorldMap: (props: unknown) => {
mockWorldMap(props);
return <div data-testid="world-map" />;
},
})); }));
describe("MapPage", () => { describe("MapPage", () => {
@@ -49,6 +53,11 @@ describe("MapPage", () => {
// Initial load should call useMapData with default filters. // Initial load should call useMapData with default filters.
expect(lastArgs).toEqual({ range: "24h", origin: "all" }); expect(lastArgs).toEqual({ range: "24h", origin: "all" });
// Map should receive country names from the hook so tooltips can show human-readable labels.
expect(mockWorldMap).toHaveBeenCalled();
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
expect(firstCallArgs).toMatchObject({ countryNames: {} });
await user.click(screen.getByRole("button", { name: /Last 7 days/i })); await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
expect(lastArgs.range).toBe("7d"); expect(lastArgs.range).toBe("7d");

View File

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

View File

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

View File

@@ -3,16 +3,19 @@ import react from "@vitejs/plugin-react";
import { resolve } from "path"; import { resolve } from "path";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
const pkg = JSON.parse( const appVersion = readFileSync(
readFileSync(resolve(__dirname, "package.json"), "utf-8"), resolve(__dirname, "../Docker/VERSION"),
) as { version: string }; "utf-8",
)
.trim()
.replace(/^v/, "");
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: { define: {
/** BanGUI application version injected at build time from package.json. */ /** BanGUI application version injected at build time from Docker/VERSION. */
__APP_VERSION__: JSON.stringify(pkg.version), __APP_VERSION__: JSON.stringify(appVersion),
}, },
resolve: { resolve: {
alias: { alias: {