feat: Stage 4 — fail2ban connection and server status

This commit is contained in:
2026-02-28 21:48:03 +01:00
parent a41a99dad4
commit 60683da3ca
13 changed files with 1085 additions and 18 deletions

View File

@@ -0,0 +1,20 @@
/**
* Dashboard API module.
*
* Wraps the `GET /api/dashboard/status` endpoint.
*/
import { get } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { ServerStatusResponse } from "../types/server";
/**
* Fetch the cached fail2ban server status from the backend.
*
* @returns The server status response containing ``online``, ``version``,
* ``active_jails``, ``total_bans``, and ``total_failures``.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchServerStatus(): Promise<ServerStatusResponse> {
return get<ServerStatusResponse>(ENDPOINTS.dashboardStatus);
}

View File

@@ -0,0 +1,179 @@
/**
* `ServerStatusBar` component.
*
* Displays a persistent bar at the top of the dashboard showing the
* fail2ban server health snapshot: connectivity status, version, active
* jail count, and aggregated ban/failure totals.
*
* Polls `GET /api/dashboard/status` every 30 seconds and on window focus
* via the {@link useServerStatus} hook.
*/
import {
Badge,
Button,
makeStyles,
Spinner,
Text,
tokens,
Tooltip,
} from "@fluentui/react-components";
import { ArrowClockwiseRegular, ShieldRegular } from "@fluentui/react-icons";
import { useServerStatus } from "../hooks/useServerStatus";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
bar: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalL,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
marginBottom: tokens.spacingVerticalL,
flexWrap: "wrap",
},
statusGroup: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
},
statGroup: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
},
statValue: {
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
},
spacer: {
flexGrow: 1,
},
errorText: {
color: tokens.colorPaletteRedForeground1,
fontSize: "12px",
},
});
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Persistent bar displaying fail2ban server health.
*
* Render this at the top of the dashboard page (and any page that should
* show live server status).
*/
export function ServerStatusBar(): JSX.Element {
const styles = useStyles();
const { status, loading, error, refresh } = useServerStatus();
return (
<div className={styles.bar} role="status" aria-label="fail2ban server status">
{/* ---------------------------------------------------------------- */}
{/* Online / Offline badge */}
{/* ---------------------------------------------------------------- */}
<div className={styles.statusGroup}>
<ShieldRegular fontSize={16} />
{loading && !status ? (
<Spinner size="extra-tiny" label="Checking…" labelPosition="after" />
) : (
<Badge
appearance="filled"
color={status?.online ? "success" : "danger"}
aria-label={status?.online ? "fail2ban online" : "fail2ban offline"}
>
{status?.online ? "Online" : "Offline"}
</Badge>
)}
</div>
{/* ---------------------------------------------------------------- */}
{/* Version */}
{/* ---------------------------------------------------------------- */}
{status?.version != null && (
<Tooltip content="fail2ban version" relationship="description">
<Text size={200} className={styles.statValue}>
v{status.version}
</Text>
</Tooltip>
)}
{/* ---------------------------------------------------------------- */}
{/* Stats (only when online) */}
{/* ---------------------------------------------------------------- */}
{status?.online === true && (
<>
<Tooltip content="Active jails" relationship="description">
<div className={styles.statGroup}>
<Text size={200}>Jails:</Text>
<Text size={200} className={styles.statValue}>
{status.active_jails}
</Text>
</div>
</Tooltip>
<Tooltip content="Currently banned IPs" relationship="description">
<div className={styles.statGroup}>
<Text size={200}>Bans:</Text>
<Text size={200} className={styles.statValue}>
{status.total_bans}
</Text>
</div>
</Tooltip>
<Tooltip content="Currently failing IPs" relationship="description">
<div className={styles.statGroup}>
<Text size={200}>Failures:</Text>
<Text size={200} className={styles.statValue}>
{status.total_failures}
</Text>
</div>
</Tooltip>
</>
)}
{/* ---------------------------------------------------------------- */}
{/* Error message */}
{/* ---------------------------------------------------------------- */}
{error != null && (
<Text className={styles.errorText} aria-live="polite">
{error}
</Text>
)}
<div className={styles.spacer} />
{/* ---------------------------------------------------------------- */}
{/* Refresh button */}
{/* ---------------------------------------------------------------- */}
<Tooltip content="Refresh server status" relationship="label">
<Button
appearance="subtle"
size="small"
icon={loading ? <Spinner size="extra-tiny" /> : <ArrowClockwiseRegular />}
onClick={refresh}
aria-label="Refresh server status"
disabled={loading}
/>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1,81 @@
/**
* `useServerStatus` hook.
*
* Fetches and periodically refreshes the fail2ban server health snapshot
* from `GET /api/dashboard/status`. Also refetches on window focus so the
* status is always fresh when the user returns to the tab.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchServerStatus } from "../api/dashboard";
import type { ServerStatus } from "../types/server";
/** How often to poll the status endpoint (milliseconds). */
const POLL_INTERVAL_MS = 30_000;
/** Return value of the {@link useServerStatus} hook. */
export interface UseServerStatusResult {
/** The most recent server status snapshot, or `null` before the first fetch. */
status: ServerStatus | null;
/** Whether a fetch is currently in flight. */
loading: boolean;
/** Error message string when the last fetch failed, otherwise `null`. */
error: string | null;
/** Manually trigger a refresh immediately. */
refresh: () => void;
}
/**
* Poll `GET /api/dashboard/status` every 30 seconds and on window focus.
*
* @returns Current status, loading state, error, and a `refresh` callback.
*/
export function useServerStatus(): UseServerStatusResult {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Use a ref so the fetch function identity is stable.
const fetchRef = useRef<() => void>(() => undefined);
const doFetch = useCallback(async (): Promise<void> => {
setLoading(true);
try {
const data = await fetchServerStatus();
setStatus(data.status);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch server status");
} finally {
setLoading(false);
}
}, []);
fetchRef.current = doFetch;
// Initial fetch + polling interval.
useEffect(() => {
void doFetch();
const id = setInterval(() => {
void fetchRef.current();
}, POLL_INTERVAL_MS);
return () => clearInterval(id);
}, [doFetch]);
// Refetch on window focus.
useEffect(() => {
const onFocus = (): void => {
void fetchRef.current();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, []);
const refresh = useCallback((): void => {
void doFetch();
}, [doFetch]);
return { status, loading, error, refresh };
}

View File

@@ -1,24 +1,29 @@
/**
* Dashboard placeholder page.
* Dashboard page.
*
* Full implementation is delivered in Stage 5.
* Shows the fail2ban server status bar at the top.
* Full ban-list implementation is delivered in Stage 5.
*/
import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { ServerStatusBar } from "../components/ServerStatusBar";
const useStyles = makeStyles({
root: {
padding: tokens.spacingVerticalXXL,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalM,
},
});
/**
* Temporary dashboard placeholder rendered until Stage 5 is complete.
* Dashboard page — renders the server status bar and a Stage 5 placeholder.
*/
export function DashboardPage(): JSX.Element {
const styles = useStyles();
return (
<div className={styles.root}>
<ServerStatusBar />
<Text as="h1" size={700} weight="semibold">
Dashboard
</Text>

View File

@@ -0,0 +1,24 @@
/**
* TypeScript interfaces that mirror the backend's server status Pydantic models.
*
* `backend/app/models/server.py`
*/
/** Cached fail2ban server health snapshot. */
export interface ServerStatus {
/** Whether fail2ban is reachable via its socket. */
online: boolean;
/** fail2ban version string, or null when offline. */
version: string | null;
/** Number of currently active jails. */
active_jails: number;
/** Aggregated current ban count across all jails. */
total_bans: number;
/** Aggregated current failure count across all jails. */
total_failures: number;
}
/** Response shape for ``GET /api/dashboard/status``. */
export interface ServerStatusResponse {
status: ServerStatus;
}