- 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).
372 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|