/** * Jails management page. * * Provides three sections in a vertically-stacked layout: * 1. **Jail Overview** — table of all jails with quick status badges and * per-row start/stop/idle/reload controls. * 2. **Ban / Unban IP** — form to manually ban or unban an IP address. * 3. **IP Lookup** — check whether an IP is currently banned and view its * geo-location details. */ import { useMemo, useState } from "react"; import { Badge, Button, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Field, Input, MessageBar, MessageBarBody, Select, Spinner, Text, Tooltip, makeStyles, tokens, type TableColumnDefinition, createTableColumn, } from "@fluentui/react-components"; import { ArrowClockwiseRegular, ArrowSyncRegular, LockClosedRegular, LockOpenRegular, PauseRegular, PlayRegular, SearchRegular, StopRegular, } from "@fluentui/react-icons"; import { useNavigate } from "react-router-dom"; import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails"; import type { JailSummary } from "../types/jail"; import { ApiError } from "../api/client"; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const useStyles = makeStyles({ root: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalL, }, section: { 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, }, sectionHeader: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: tokens.spacingHorizontalM, paddingBottom: tokens.spacingVerticalS, borderBottomWidth: "1px", borderBottomStyle: "solid", borderBottomColor: tokens.colorNeutralStroke2, }, tableWrapper: { overflowX: "auto" }, centred: { display: "flex", justifyContent: "center", alignItems: "center", padding: tokens.spacingVerticalXXL, }, mono: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: tokens.fontSizeBase200, }, formRow: { display: "flex", flexWrap: "wrap", gap: tokens.spacingHorizontalM, alignItems: "flex-end", }, formField: { minWidth: "180px", flexGrow: 1 }, actionRow: { display: "flex", flexWrap: "wrap", gap: tokens.spacingHorizontalS, }, lookupResult: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalS, marginTop: tokens.spacingVerticalS, padding: tokens.spacingVerticalS, backgroundColor: tokens.colorNeutralBackground2, 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, }, lookupRow: { display: "flex", gap: tokens.spacingHorizontalM, flexWrap: "wrap", alignItems: "center", }, lookupLabel: { fontWeight: tokens.fontWeightSemibold }, }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function fmtSeconds(s: number): string { if (s < 0) return "permanent"; if (s < 60) return `${String(s)}s`; if (s < 3600) return `${String(Math.round(s / 60))}m`; return `${String(Math.round(s / 3600))}h`; } // --------------------------------------------------------------------------- // Sub-component: Jail overview section // --------------------------------------------------------------------------- function JailOverviewSection(): React.JSX.Element { const styles = useStyles(); const navigate = useNavigate(); const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = useJails(); const [opError, setOpError] = useState(null); const jailColumns = useMemo[]>( () => [ createTableColumn({ columnId: "name", renderHeaderCell: () => "Jail", renderCell: (j) => ( ), }), createTableColumn({ columnId: "status", renderHeaderCell: () => "Status", renderCell: (j) => { if (!j.running) return stopped; if (j.idle) return idle; return running; }, }), createTableColumn({ columnId: "backend", renderHeaderCell: () => "Backend", renderCell: (j) => {j.backend}, }), createTableColumn({ columnId: "banned", renderHeaderCell: () => "Banned", renderCell: (j) => ( {j.status ? String(j.status.currently_banned) : "—"} ), }), createTableColumn({ columnId: "failed", renderHeaderCell: () => "Failed", renderCell: (j) => ( {j.status ? String(j.status.currently_failed) : "—"} ), }), createTableColumn({ columnId: "findTime", renderHeaderCell: () => "Find Time", renderCell: (j) => {fmtSeconds(j.find_time)}, }), createTableColumn({ columnId: "banTime", renderHeaderCell: () => "Ban Time", renderCell: (j) => {fmtSeconds(j.ban_time)}, }), createTableColumn({ columnId: "maxRetry", renderHeaderCell: () => "Max Retry", renderCell: (j) => {String(j.max_retry)}, }), ], [navigate], ); const handle = (fn: () => Promise): void => { setOpError(null); fn().catch((err: unknown) => { setOpError(err instanceof Error ? err.message : String(err)); }); }; return (
Jail Overview {total > 0 && ( {String(total)} )}
{opError && ( {opError} )} {error && ( Failed to load jails: {error} )} {loading && jails.length === 0 ? (
) : (
j.name} focusMode="composite" > {({ renderHeaderCell }) => ( {renderHeaderCell()} )} > {({ item }) => ( key={item.name}> {({ renderCell, columnId }) => { if (columnId === "status") { return (
{renderCell(item)}
); } return {renderCell(item)}; }} )}
)}
); } // --------------------------------------------------------------------------- // Sub-component: Ban / Unban IP form // --------------------------------------------------------------------------- interface BanUnbanFormProps { jailNames: string[]; onBan: (jail: string, ip: string) => Promise; onUnban: (ip: string, jail?: string) => Promise; } function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element { const styles = useStyles(); const [banIpVal, setBanIpVal] = useState(""); const [banJail, setBanJail] = useState(""); const [unbanIpVal, setUnbanIpVal] = useState(""); const [unbanJail, setUnbanJail] = useState(""); const [formError, setFormError] = useState(null); const [formSuccess, setFormSuccess] = useState(null); const handleBan = (): void => { setFormError(null); setFormSuccess(null); if (!banIpVal.trim() || !banJail) { setFormError("Both IP address and jail are required."); return; } onBan(banJail, banIpVal.trim()) .then(() => { setFormSuccess(`${banIpVal.trim()} banned in ${banJail}.`); setBanIpVal(""); }) .catch((err: unknown) => { const msg = err instanceof ApiError ? `${String(err.status)}: ${err.body}` : err instanceof Error ? err.message : String(err); setFormError(msg); }); }; const handleUnban = (fromAllJails: boolean): void => { setFormError(null); setFormSuccess(null); if (!unbanIpVal.trim()) { setFormError("IP address is required."); return; } const jail = fromAllJails ? undefined : unbanJail || undefined; onUnban(unbanIpVal.trim(), jail) .then(() => { const scope = jail ?? "all jails"; setFormSuccess(`${unbanIpVal.trim()} unbanned from ${scope}.`); setUnbanIpVal(""); setUnbanJail(""); }) .catch((err: unknown) => { const msg = err instanceof ApiError ? `${String(err.status)}: ${err.body}` : err instanceof Error ? err.message : String(err); setFormError(msg); }); }; return (
Ban / Unban IP
{formError && ( {formError} )} {formSuccess && ( {formSuccess} )} {/* Ban row */} Ban an IP
{ setBanIpVal(d.value); }} />
{/* Unban row */} Unban an IP
{ setUnbanIpVal(d.value); }} />
); } // --------------------------------------------------------------------------- // Sub-component: IP Lookup section // --------------------------------------------------------------------------- function IpLookupSection(): React.JSX.Element { const styles = useStyles(); const { result, loading, error, lookup, clear } = useIpLookup(); const [inputVal, setInputVal] = useState(""); const handleLookup = (): void => { if (inputVal.trim()) { lookup(inputVal.trim()); } }; return (
IP Lookup
{ setInputVal(d.value); clear(); }} onKeyDown={(e) => { if (e.key === "Enter") handleLookup(); }} />
{error && ( {error} )} {result && (
IP: {result.ip}
Currently banned in: {result.currently_banned_in.length === 0 ? ( not banned ) : (
{result.currently_banned_in.map((j) => ( {j} ))}
)}
{result.geo && ( <> {result.geo.country_name && (
Country: {result.geo.country_name} {result.geo.country_code ? ` (${result.geo.country_code})` : ""}
)} {result.geo.org && (
Organisation: {result.geo.org}
)} {result.geo.asn && (
ASN: {result.geo.asn}
)} )}
)}
); } // --------------------------------------------------------------------------- // Page component // --------------------------------------------------------------------------- /** * Jails management page. * * Renders three sections: Jail Overview, Ban/Unban IP, and IP Lookup. */ export function JailsPage(): React.JSX.Element { const styles = useStyles(); const { jails } = useJails(); const { banIp, unbanIp } = useActiveBans(); const jailNames = jails.map((j) => j.name); return (
Jails
); }