Show BanGUI app version in sidebar, fix version tooltips

- Inject __APP_VERSION__ at build time via vite.config.ts define (reads
  frontend/package.json#version); declare the global in vite-env.d.ts.
- Render 'BanGUI v{__APP_VERSION__}' in the sidebar footer (MainLayout)
  when expanded; hidden when collapsed.
- Rename fail2ban version tooltip to 'fail2ban daemon version' in
  ServerStatusBar so it is visually distinct from the app version.
- Sync frontend/package.json version (0.9.0 → 0.9.3) to match
  Docker/VERSION; update release.sh to keep them in sync on every bump.
- Add vitest define stub for __APP_VERSION__ so tests compile cleanly.
- Add ServerStatusBar and MainLayout test suites (10 new test cases).
This commit is contained in:
2026-03-16 19:45:55 +01:00
parent abb224e01b
commit e7834a888e
10 changed files with 295 additions and 59 deletions

View File

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

View File

@@ -109,7 +109,7 @@ export function ServerStatusBar(): React.JSX.Element {
{/* Version */}
{/* ---------------------------------------------------------------- */}
{status?.version != null && (
<Tooltip content="fail2ban version" relationship="description">
<Tooltip content="fail2ban daemon version" relationship="description">
<Text size={200} className={styles.statValue}>
v{status.version}
</Text>

View File

@@ -0,0 +1,151 @@
/**
* Tests for the ServerStatusBar component.
*
* Covers loading state, online / offline rendering, and correct tooltip
* wording that distinguishes the fail2ban daemon version from the BanGUI
* application version.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ServerStatusBar } from "../ServerStatusBar";
// ---------------------------------------------------------------------------
// Mock useServerStatus so tests never touch the network.
// ---------------------------------------------------------------------------
vi.mock("../../hooks/useServerStatus");
import { useServerStatus } from "../../hooks/useServerStatus";
const mockedUseServerStatus = vi.mocked(useServerStatus);
function renderBar(): void {
render(
<FluentProvider theme={webLightTheme}>
<ServerStatusBar />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ServerStatusBar", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a spinner while the initial load is in progress", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
loading: true,
error: null,
refresh: vi.fn(),
});
renderBar();
// The status-area spinner is labelled "Checking\u2026".
expect(screen.getByText("Checking\u2026")).toBeInTheDocument();
});
it("renders an Online badge when the server is reachable", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.1.0",
active_jails: 3,
total_bans: 10,
total_failures: 5,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("Online")).toBeInTheDocument();
});
it("renders an Offline badge when the server is unreachable", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: false,
version: null,
active_jails: 0,
total_bans: 0,
total_failures: 0,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("Offline")).toBeInTheDocument();
});
it("displays the daemon version string when available", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.2.3",
active_jails: 1,
total_bans: 0,
total_failures: 0,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
});
it("does not render the version element when version is null", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: false,
version: null,
active_jails: 0,
total_bans: 0,
total_failures: 0,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
// No version string should appear in the document.
expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
});
it("shows jail / ban / failure counts when the server is online", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.0.0",
active_jails: 4,
total_bans: 21,
total_failures: 99,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("21")).toBeInTheDocument();
expect(screen.getByText("99")).toBeInTheDocument();
});
it("renders an error message when the status fetch fails", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
loading: false,
error: "Network error",
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});

View File

@@ -145,6 +145,16 @@ const useStyles = makeStyles({
padding: tokens.spacingVerticalS,
flexShrink: 0,
},
versionText: {
display: "block",
color: tokens.colorNeutralForeground4,
fontSize: "11px",
paddingLeft: tokens.spacingHorizontalS,
paddingRight: tokens.spacingHorizontalS,
paddingBottom: tokens.spacingVerticalXS,
whiteSpace: "nowrap",
overflow: "hidden",
},
// Main content
main: {
@@ -301,6 +311,11 @@ export function MainLayout(): React.JSX.Element {
{/* Footer — Logout */}
<div className={styles.sidebarFooter}>
{!collapsed && (
<Text className={styles.versionText}>
BanGUI v{__APP_VERSION__}
</Text>
)}
<Tooltip
content={collapsed ? "Sign out" : ""}
relationship="label"

View File

@@ -0,0 +1,78 @@
/**
* Tests for the MainLayout component.
*
* Covers:
* - BanGUI application version displayed in the footer when the sidebar is expanded.
* - Version text hidden when the sidebar is collapsed.
* - Navigation items rendered correctly.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MainLayout } from "../../layouts/MainLayout";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../providers/AuthProvider", () => ({
useAuth: () => ({ logout: vi.fn() }),
}));
vi.mock("../../hooks/useServerStatus", () => ({
useServerStatus: () => ({
status: { online: true, version: "1.0.0", active_jails: 1, total_bans: 0, total_failures: 0 },
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
vi.mock("../../hooks/useBlocklist", () => ({
useBlocklistStatus: () => ({ hasErrors: false }),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderLayout(): void {
render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/"]}>
<MainLayout />
</MemoryRouter>
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("MainLayout", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the navigation sidebar", () => {
renderLayout();
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
});
it("shows the BanGUI version in the sidebar footer when expanded", () => {
renderLayout();
// __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define.
expect(screen.getByText("BanGUI v0.0.0-test")).toBeInTheDocument();
});
it("hides the BanGUI version text when the sidebar is collapsed", async () => {
renderLayout();
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
await userEvent.click(toggleButton);
expect(screen.queryByText("BanGUI v0.0.0-test")).not.toBeInTheDocument();
});
});

View File

@@ -7,3 +7,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** BanGUI application version — injected at build time via Vite define. */
declare const __APP_VERSION__: string;

View File

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

View File

@@ -4,6 +4,10 @@ import { resolve } from "path";
export default defineConfig({
plugins: [react()],
define: {
/** Stub app version for tests — mirrors the vite.config.ts define. */
__APP_VERSION__: JSON.stringify("0.0.0-test"),
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),