Stage 11: polish, cross-cutting concerns & hardening

- 11.1 MainLayout health indicator: warning MessageBar when fail2ban offline
- 11.2 formatDate utility + TimezoneProvider + GET /api/setup/timezone
- 11.3 Responsive sidebar: auto-collapse <640px, media query listener
- 11.4 PageFeedback (PageLoading/PageError/PageEmpty), BanTable updated
- 11.5 prefers-reduced-motion: disable sidebar transition
- 11.6 WorldMap ARIA: role/tabIndex/aria-label/onKeyDown for countries
- 11.7 Health transition logging (fail2ban_came_online/went_offline)
- 11.8 Global handlers: Fail2BanConnectionError/ProtocolError -> 502
- 11.9 379 tests pass, 82% coverage, ruff+mypy+tsc+eslint clean
- Timezone endpoint: setup_service.get_timezone, 5 new tests
This commit is contained in:
2026-03-01 15:59:06 +01:00
parent 1efa0e973b
commit 1cdc97a729
19 changed files with 649 additions and 45 deletions

View File

@@ -6,11 +6,14 @@
* icon-only (48 px) on small screens.
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
makeStyles,
mergeClasses,
MessageBar,
MessageBarBody,
MessageBarTitle,
Text,
tokens,
Tooltip,
@@ -27,6 +30,7 @@ import {
} from "@fluentui/react-icons";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider";
import { useServerStatus } from "../hooks/useServerStatus";
// ---------------------------------------------------------------------------
// Styles
@@ -56,6 +60,10 @@ const useStyles = makeStyles({
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,
@@ -137,6 +145,13 @@ const useStyles = makeStyles({
flexGrow: 1,
overflow: "auto",
},
warningBar: {
flexShrink: 0,
paddingLeft: tokens.spacingHorizontalM,
paddingRight: tokens.spacingHorizontalM,
paddingTop: tokens.spacingVerticalXS,
paddingBottom: tokens.spacingVerticalXS,
},
content: {
flexGrow: 1,
maxWidth: "1440px",
@@ -180,7 +195,23 @@ export function MainLayout(): React.JSX.Element {
const styles = useStyles();
const { logout } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
// 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();
/** 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);
@@ -270,6 +301,18 @@ export function MainLayout(): React.JSX.Element {
{/* 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>
)}
<div className={styles.content}>
<Outlet />
</div>