Files
BanGUI/frontend/src/layouts/MainLayout.tsx
Lukas e7834a888e 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).
2026-03-16 19:45:55 +01:00

372 lines
11 KiB
TypeScript

/**
* Main application layout.
*
* Provides the persistent sidebar navigation and the main content area
* for all authenticated pages. The sidebar collapses from 240 px to
* icon-only (48 px) on small screens.
*/
import { useCallback, useEffect, useState } from "react";
import {
Badge,
Button,
makeStyles,
mergeClasses,
MessageBar,
MessageBarBody,
MessageBarTitle,
Text,
tokens,
Tooltip,
} from "@fluentui/react-components";
import {
GridRegular,
MapRegular,
ShieldRegular,
SettingsRegular,
HistoryRegular,
ListRegular,
SignOutRegular,
NavigationRegular,
} from "@fluentui/react-icons";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider";
import { useServerStatus } from "../hooks/useServerStatus";
import { useBlocklistStatus } from "../hooks/useBlocklist";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const SIDEBAR_FULL = "240px";
const SIDEBAR_COLLAPSED = "48px";
const useStyles = makeStyles({
root: {
display: "flex",
height: "100vh",
overflow: "hidden",
backgroundColor: tokens.colorNeutralBackground3,
},
// Sidebar
sidebar: {
display: "flex",
flexDirection: "column",
width: SIDEBAR_FULL,
minWidth: SIDEBAR_COLLAPSED,
backgroundColor: tokens.colorNeutralBackground1,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
transition: "width 200ms ease",
overflow: "hidden",
flexShrink: 0,
// Honour the OS/browser reduced-motion preference.
"@media (prefers-reduced-motion: reduce)": {
transition: "none",
},
},
sidebarCollapsed: {
width: SIDEBAR_COLLAPSED,
},
sidebarHeader: {
display: "flex",
alignItems: "center",
height: "52px",
paddingLeft: tokens.spacingHorizontalM,
paddingRight: tokens.spacingHorizontalS,
gap: tokens.spacingHorizontalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
flexShrink: 0,
},
logo: {
fontWeight: 600,
fontSize: "16px",
whiteSpace: "nowrap",
overflow: "hidden",
color: tokens.colorBrandForeground1,
flexGrow: 1,
},
// Nav items list
navList: {
display: "flex",
flexDirection: "column",
gap: "2px",
padding: tokens.spacingVerticalS,
overflowY: "auto",
flexGrow: 1,
},
navLinkContent: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
flexGrow: 1,
},
navLink: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalS}`,
borderRadius: tokens.borderRadiusMedium,
textDecoration: "none",
color: tokens.colorNeutralForeground2,
whiteSpace: "nowrap",
overflow: "hidden",
":hover": {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground1,
},
},
navLinkActive: {
backgroundColor: tokens.colorNeutralBackground1Selected,
color: tokens.colorBrandForeground1,
":hover": {
backgroundColor: tokens.colorNeutralBackground1Selected,
},
},
navLabel: {
fontSize: "14px",
lineHeight: "20px",
overflow: "hidden",
textOverflow: "ellipsis",
},
// Sidebar footer (logout)
sidebarFooter: {
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
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: {
display: "flex",
flexDirection: "column",
flexGrow: 1,
overflow: "auto",
},
warningBar: {
flexShrink: 0,
paddingLeft: tokens.spacingHorizontalM,
paddingRight: tokens.spacingHorizontalM,
paddingTop: tokens.spacingVerticalXS,
paddingBottom: tokens.spacingVerticalXS,
},
content: {
flexGrow: 1,
maxWidth: "1440px",
width: "100%",
margin: "0 auto",
padding: tokens.spacingVerticalL,
},
});
// ---------------------------------------------------------------------------
// Nav item data
// ---------------------------------------------------------------------------
interface NavItem {
label: string;
to: string;
icon: React.ReactElement;
end?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", to: "/", icon: <GridRegular />, end: true },
{ label: "World Map", to: "/map", icon: <MapRegular /> },
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
{ label: "History", to: "/history", icon: <HistoryRegular /> },
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Main application shell with sidebar navigation and content area.
*
* Renders child routes via `<Outlet />`. Use inside React Router
* as a layout route wrapping all authenticated pages.
*/
export function MainLayout(): React.JSX.Element {
const styles = useStyles();
const { logout } = useAuth();
const navigate = useNavigate();
// Initialise collapsed based on screen width so narrow viewports start
// with the icon-only sidebar rather than the full-width one.
const [collapsed, setCollapsed] = useState(() => window.innerWidth < 640);
const { status } = useServerStatus();
const { hasErrors: blocklistHasErrors } = useBlocklistStatus();
/** True only after the first successful poll and fail2ban is unreachable. */
const serverOffline = status !== null && !status.online;
// Auto-collapse / auto-expand when the viewport crosses the 640 px breakpoint.
useEffect(() => {
const mq = window.matchMedia("(max-width: 639px)");
const handler = (e: MediaQueryListEvent): void => {
setCollapsed(e.matches);
};
mq.addEventListener("change", handler);
return (): void => { mq.removeEventListener("change", handler); };
}, []);
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
const handleLogout = useCallback(async () => {
await logout();
navigate("/login", { replace: true });
}, [logout, navigate]);
return (
<div className={styles.root}>
{/* ---------------------------------------------------------------- */}
{/* Sidebar */}
{/* ---------------------------------------------------------------- */}
<nav
className={mergeClasses(
styles.sidebar,
collapsed && styles.sidebarCollapsed,
)}
aria-label="Main navigation"
>
{/* Header */}
<div className={styles.sidebarHeader}>
{!collapsed && <span className={styles.logo}>BanGUI</span>}
<Tooltip content={collapsed ? "Expand sidebar" : "Collapse sidebar"} relationship="label">
<Button
appearance="subtle"
icon={<NavigationRegular />}
onClick={toggleCollapse}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
/>
</Tooltip>
</div>
{/* Nav links */}
<ul className={styles.navList} role="list" aria-label="Pages">
{NAV_ITEMS.map((item) => {
const showBadge = item.to === "/blocklists" && blocklistHasErrors;
return (
<li key={item.to} role="listitem">
<Tooltip
content={collapsed ? item.label : ""}
relationship="label"
positioning="after"
>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
mergeClasses(
styles.navLink,
isActive && styles.navLinkActive,
)
}
aria-label={collapsed ? item.label : undefined}
>
<span className={styles.navLinkContent}>
{item.icon}
{!collapsed && (
<Text className={styles.navLabel}>{item.label}</Text>
)}
</span>
{showBadge && (
<Badge
appearance="filled"
color="warning"
size="extra-small"
aria-label="Import errors"
/>
)}
</NavLink>
</Tooltip>
</li>
);
})}
</ul>
{/* Footer — Logout */}
<div className={styles.sidebarFooter}>
{!collapsed && (
<Text className={styles.versionText}>
BanGUI v{__APP_VERSION__}
</Text>
)}
<Tooltip
content={collapsed ? "Sign out" : ""}
relationship="label"
positioning="after"
>
<Button
appearance="subtle"
icon={<SignOutRegular />}
onClick={() => void handleLogout()}
aria-label="Sign out"
style={{ width: "100%", justifyContent: collapsed ? "center" : "flex-start" }}
>
{!collapsed && "Sign out"}
</Button>
</Tooltip>
</div>
</nav>
{/* ---------------------------------------------------------------- */}
{/* Main content */}
{/* ---------------------------------------------------------------- */}
<main className={styles.main}>
{/* Connection health warning — shown when fail2ban is unreachable */}
{serverOffline && (
<div className={styles.warningBar} role="alert">
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>fail2ban Unreachable</MessageBarTitle>
The connection to the fail2ban server has been lost. Some
features may be temporarily unavailable.
</MessageBarBody>
</MessageBar>
</div>
)}
{/* Blocklist import error warning — shown when the last scheduled import had errors */}
{blocklistHasErrors && (
<div className={styles.warningBar} role="alert">
<MessageBar intent="warning">
<MessageBarBody>
<MessageBarTitle>Blocklist Import Errors</MessageBarTitle>
The most recent blocklist import encountered errors. Check the
Blocklists page for details.
</MessageBarBody>
</MessageBar>
</div>
)}
<div className={styles.content}>
<Outlet />
</div>
</main>
</div>
);
}