397 lines
10 KiB
TypeScript
397 lines
10 KiB
TypeScript
/**
|
||
* `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 {
|
||
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 { formatTimestamp } from "../../utils/formatDate";
|
||
import {
|
||
ArrowClockwiseRegular,
|
||
ChevronLeftRegular,
|
||
ChevronRightRegular,
|
||
DismissRegular,
|
||
SearchRegular,
|
||
} from "@fluentui/react-icons";
|
||
import type { ActiveBan } from "../../types/jail";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 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,
|
||
},
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<BanRow>[] = [
|
||
createTableColumn<BanRow>({
|
||
columnId: "ip",
|
||
renderHeaderCell: () => "IP Address",
|
||
renderCell: ({ ban }) => (
|
||
<Text
|
||
style={{
|
||
fontFamily: "Consolas, 'Courier New', monospace",
|
||
fontSize: tokens.fontSizeBase200,
|
||
}}
|
||
>
|
||
{ban.ip}
|
||
</Text>
|
||
),
|
||
}),
|
||
createTableColumn<BanRow>({
|
||
columnId: "country",
|
||
renderHeaderCell: () => "Country",
|
||
renderCell: ({ ban }) =>
|
||
ban.country ? (
|
||
<Text size={200}>{ban.country}</Text>
|
||
) : (
|
||
<Text size={200} style={{ color: tokens.colorNeutralForeground4 }}>
|
||
—
|
||
</Text>
|
||
),
|
||
}),
|
||
createTableColumn<BanRow>({
|
||
columnId: "banned_at",
|
||
renderHeaderCell: () => "Banned At",
|
||
renderCell: ({ ban }) => (
|
||
<Text size={200}>{ban.banned_at ? formatTimestamp(ban.banned_at) : "—"}</Text>
|
||
),
|
||
}),
|
||
createTableColumn<BanRow>({
|
||
columnId: "expires_at",
|
||
renderHeaderCell: () => "Expires At",
|
||
renderCell: ({ ban }) => (
|
||
<Text size={200}>{ban.expires_at ? formatTimestamp(ban.expires_at) : "—"}</Text>
|
||
),
|
||
}),
|
||
createTableColumn<BanRow>({
|
||
columnId: "actions",
|
||
renderHeaderCell: () => "",
|
||
renderCell: ({ ban, onUnban }) => (
|
||
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
|
||
<Button
|
||
size="small"
|
||
appearance="subtle"
|
||
icon={<DismissRegular />}
|
||
onClick={() => {
|
||
onUnban(ban.ip);
|
||
}}
|
||
aria-label={`Unban ${ban.ip}`}
|
||
/>
|
||
</Tooltip>
|
||
),
|
||
}),
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Props
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Props for {@link BannedIpsSection}. */
|
||
export interface BannedIpsSectionProps {
|
||
items: ActiveBan[];
|
||
total: number;
|
||
page: number;
|
||
pageSize: number;
|
||
search: string;
|
||
loading: boolean;
|
||
error: string | null;
|
||
opError: string | null;
|
||
onSearch: (term: string) => void;
|
||
onPageChange: (page: number) => void;
|
||
onPageSizeChange: (size: number) => void;
|
||
onRefresh: () => void;
|
||
onUnban: (ip: string) => void;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Component
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Paginated section showing currently banned IPs for a single jail.
|
||
*
|
||
* @param props - {@link BannedIpsSectionProps}
|
||
*/
|
||
export function BannedIpsSection({
|
||
items,
|
||
total,
|
||
page,
|
||
pageSize,
|
||
search,
|
||
loading,
|
||
error,
|
||
opError,
|
||
onSearch,
|
||
onPageChange,
|
||
onPageSizeChange,
|
||
onRefresh,
|
||
onUnban,
|
||
}: BannedIpsSectionProps): React.JSX.Element {
|
||
const styles = useStyles();
|
||
|
||
const rows: BanRow[] = items.map((ban) => ({
|
||
ban,
|
||
onUnban,
|
||
}));
|
||
|
||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
|
||
|
||
return (
|
||
<div className={styles.root}>
|
||
{/* Section header */}
|
||
<div className={styles.header}>
|
||
<div className={styles.headerLeft}>
|
||
<Text as="h2" size={500} weight="semibold">
|
||
Currently Banned IPs
|
||
</Text>
|
||
<Badge appearance="tint">{String(total)}</Badge>
|
||
</div>
|
||
<Button
|
||
size="small"
|
||
appearance="subtle"
|
||
icon={<ArrowClockwiseRegular />}
|
||
onClick={onRefresh}
|
||
aria-label="Refresh banned IPs"
|
||
/>
|
||
</div>
|
||
|
||
{/* Toolbar */}
|
||
<div className={styles.toolbar}>
|
||
<div className={styles.searchField}>
|
||
<Field label="Search by IP">
|
||
<Input
|
||
aria-label="Search by IP"
|
||
contentBefore={<SearchRegular />}
|
||
placeholder="e.g. 192.168"
|
||
value={search}
|
||
onChange={(_, d) => {
|
||
onSearch(d.value);
|
||
}}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error bars */}
|
||
{error && (
|
||
<MessageBar intent="error">
|
||
<MessageBarBody>{error}</MessageBarBody>
|
||
</MessageBar>
|
||
)}
|
||
{opError && (
|
||
<MessageBar intent="error">
|
||
<MessageBarBody>{opError}</MessageBarBody>
|
||
</MessageBar>
|
||
)}
|
||
|
||
{/* Table */}
|
||
{loading ? (
|
||
<div className={styles.centred}>
|
||
<Spinner label="Loading banned IPs…" />
|
||
</div>
|
||
) : items.length === 0 ? (
|
||
<div className={styles.centred}>
|
||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||
No IPs currently banned in this jail.
|
||
</Text>
|
||
</div>
|
||
) : (
|
||
<div className={styles.tableWrapper}>
|
||
<DataGrid
|
||
items={rows}
|
||
columns={columns}
|
||
getRowId={(row: BanRow) => row.ban.ip}
|
||
focusMode="composite"
|
||
>
|
||
<DataGridHeader>
|
||
<DataGridRow>
|
||
{({ renderHeaderCell }) => (
|
||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||
)}
|
||
</DataGridRow>
|
||
</DataGridHeader>
|
||
<DataGridBody<BanRow>>
|
||
{({ item, rowId }) => (
|
||
<DataGridRow<BanRow> key={rowId}>
|
||
{({ renderCell }) => (
|
||
<DataGridCell>{renderCell(item)}</DataGridCell>
|
||
)}
|
||
</DataGridRow>
|
||
)}
|
||
</DataGridBody>
|
||
</DataGrid>
|
||
</div>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{total > 0 && (
|
||
<div className={styles.pagination}>
|
||
<div className={styles.pageSizeWrapper}>
|
||
<Text size={200}>Rows per page:</Text>
|
||
<Dropdown
|
||
aria-label="Rows per page"
|
||
value={String(pageSize)}
|
||
selectedOptions={[String(pageSize)]}
|
||
onOptionSelect={(_, d) => {
|
||
const newSize = Number(d.optionValue);
|
||
if (!Number.isNaN(newSize)) {
|
||
onPageSizeChange(newSize);
|
||
onPageChange(1);
|
||
}
|
||
}}
|
||
style={{ minWidth: "80px" }}
|
||
>
|
||
{PAGE_SIZE_OPTIONS.map((n) => (
|
||
<Option key={n} value={String(n)}>
|
||
{String(n)}
|
||
</Option>
|
||
))}
|
||
</Dropdown>
|
||
</div>
|
||
|
||
<Text size={200}>
|
||
{String((page - 1) * pageSize + 1)}–
|
||
{String(Math.min(page * pageSize, total))} of {String(total)}
|
||
</Text>
|
||
|
||
<Button
|
||
size="small"
|
||
appearance="subtle"
|
||
icon={<ChevronLeftRegular />}
|
||
disabled={page <= 1}
|
||
onClick={() => {
|
||
onPageChange(Math.max(1, page - 1));
|
||
}}
|
||
aria-label="Previous page"
|
||
/>
|
||
<Button
|
||
size="small"
|
||
appearance="subtle"
|
||
icon={<ChevronRightRegular />}
|
||
disabled={page >= totalPages}
|
||
onClick={() => {
|
||
onPageChange(page + 1);
|
||
}}
|
||
aria-label="Next page"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|