feat: Stage 4 — fail2ban connection and server status
This commit is contained in:
20
frontend/src/api/dashboard.ts
Normal file
20
frontend/src/api/dashboard.ts
Normal 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);
|
||||
}
|
||||
179
frontend/src/components/ServerStatusBar.tsx
Normal file
179
frontend/src/components/ServerStatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
frontend/src/hooks/useServerStatus.ts
Normal file
81
frontend/src/hooks/useServerStatus.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
24
frontend/src/types/server.ts
Normal file
24
frontend/src/types/server.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user