/** * `BannedIpsSection` component. * * Displays a paginated table of IPs currently banned in a specific fail2ban * jail. Supports server-side search filtering (debounced), page navigation, * page-size selection, and per-row unban actions. * * Only the current page is geo-enriched by the backend, so the component * remains fast even when a jail contains thousands of banned IPs. */ import { useCallback, useEffect, useRef, useState } from "react"; import { Badge, Button, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Dropdown, Field, Input, MessageBar, MessageBarBody, Option, Spinner, Text, Tooltip, makeStyles, tokens, type TableColumnDefinition, createTableColumn, } from "@fluentui/react-components"; import { ArrowClockwiseRegular, ChevronLeftRegular, ChevronRightRegular, DismissRegular, SearchRegular, } from "@fluentui/react-icons"; import { fetchJailBannedIps, unbanIp } from "../../api/jails"; import type { ActiveBan } from "../../types/jail"; import { ApiError } from "../../api/client"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Debounce delay in milliseconds for the search input. */ const SEARCH_DEBOUNCE_MS = 300; /** Available page-size options. */ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const useStyles = makeStyles({ root: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalS, 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, padding: tokens.spacingVerticalM, }, header: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: tokens.spacingHorizontalM, paddingBottom: tokens.spacingVerticalS, borderBottomWidth: "1px", borderBottomStyle: "solid", borderBottomColor: tokens.colorNeutralStroke2, }, headerLeft: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, }, toolbar: { display: "flex", alignItems: "flex-end", gap: tokens.spacingHorizontalS, flexWrap: "wrap", }, searchField: { minWidth: "200px", flexGrow: 1, }, tableWrapper: { overflowX: "auto", }, centred: { display: "flex", justifyContent: "center", alignItems: "center", padding: tokens.spacingVerticalXXL, }, pagination: { display: "flex", alignItems: "center", justifyContent: "flex-end", gap: tokens.spacingHorizontalS, paddingTop: tokens.spacingVerticalS, flexWrap: "wrap", }, pageSizeWrapper: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalXS, }, mono: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: tokens.fontSizeBase200, }, }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Format an ISO 8601 timestamp for compact display. * * @param iso - ISO 8601 string or `null`. * @returns A locale time string, or `"—"` when `null`. */ function fmtTime(iso: string | null): string { if (!iso) return "—"; try { return new Date(iso).toLocaleString(undefined, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch { return iso; } } // --------------------------------------------------------------------------- // Column definitions // --------------------------------------------------------------------------- /** A row item augmented with an `onUnban` callback for the row action. */ interface BanRow { ban: ActiveBan; onUnban: (ip: string) => void; } const columns: TableColumnDefinition[] = [ createTableColumn({ columnId: "ip", renderHeaderCell: () => "IP Address", renderCell: ({ ban }) => ( {ban.ip} ), }), createTableColumn({ columnId: "country", renderHeaderCell: () => "Country", renderCell: ({ ban }) => ban.country ? ( {ban.country} ) : ( ), }), createTableColumn({ columnId: "banned_at", renderHeaderCell: () => "Banned At", renderCell: ({ ban }) => {fmtTime(ban.banned_at)}, }), createTableColumn({ columnId: "expires_at", renderHeaderCell: () => "Expires At", renderCell: ({ ban }) => {fmtTime(ban.expires_at)}, }), createTableColumn({ columnId: "actions", renderHeaderCell: () => "", renderCell: ({ ban, onUnban }) => (