/**
* Jail detail page.
*
* Displays full configuration and state for a single fail2ban jail:
* - Status badges and control buttons (start, stop, idle, reload)
* - Log paths, fail-regex, ignore-regex patterns
* - Date pattern, encoding, and actions
* - Ignore list management (add / remove IPs)
*/
import { useState } from "react";
import {
Badge,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
Switch,
Text,
Tooltip,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { useCommonSectionStyles } from "../theme/commonStyles";
import {
ArrowClockwiseRegular,
ArrowLeftRegular,
ArrowSyncRegular,
DismissRegular,
PauseRegular,
PlayRegular,
StopRegular,
} from "@fluentui/react-icons";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useJailDetail, useJailBannedIps } from "../hooks/useJails";
import { formatSeconds } from "../utils/formatDate";
import type { Jail } from "../types/jail";
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalL,
},
breadcrumb: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
},
headerRow: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
flexWrap: "wrap",
},
controlRow: {
display: "flex",
flexWrap: "wrap",
gap: tokens.spacingHorizontalS,
},
grid: {
display: "grid",
gridTemplateColumns: "max-content 1fr",
gap: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalM}`,
alignItems: "baseline",
},
label: {
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorNeutralForeground2,
},
mono: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
},
codeList: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXXS,
paddingTop: tokens.spacingVerticalXS,
},
codeItem: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
padding: `2px ${tokens.spacingHorizontalS}`,
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusSmall,
wordBreak: "break-all",
},
centred: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: tokens.spacingVerticalXXL,
},
ignoreRow: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusSmall,
},
formRow: {
display: "flex",
gap: tokens.spacingHorizontalM,
alignItems: "flex-end",
flexWrap: "wrap",
},
formField: { minWidth: "200px", flexGrow: 1 },
});
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function CodeList({ items, empty }: { items: string[]; empty: string }): React.JSX.Element {
const styles = useStyles();
if (items.length === 0) {
return {empty} ;
}
return (
{items.map((item, i) => (
{item}
))}
);
}
// ---------------------------------------------------------------------------
// Sub-component: Jail info card
// ---------------------------------------------------------------------------
interface JailInfoProps {
jail: Jail;
onRefresh: () => void;
onStart: () => Promise;
onStop: () => Promise;
onSetIdle: (on: boolean) => Promise;
onReload: () => Promise;
}
function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const navigate = useNavigate();
const [ctrlError, setCtrlError] = useState(null);
const handle =
(fn: () => Promise, postNavigate = false) =>
(): void => {
setCtrlError(null);
fn()
.then(() => {
if (postNavigate) {
navigate("/jails");
} else {
onRefresh();
}
})
.catch((err: unknown) => {
const msg =
err instanceof Error
? err.message
: String(err);
setCtrlError(msg);
});
};
return (
{jail.name}
{jail.running ? (
jail.idle ? (
idle
) : (
running
)
) : (
stopped
)}
}
onClick={onRefresh}
aria-label="Refresh"
/>
{ctrlError && (
{ctrlError}
)}
{/* Control buttons */}
{jail.running ? (
}
onClick={handle(onStop)}
>
Stop
) : (
}
onClick={handle(onStart)}
>
Start
)}
}
onClick={handle(() => onSetIdle(!jail.idle))}
disabled={!jail.running}
>
{jail.idle ? "Resume" : "Set Idle"}
}
onClick={handle(onReload)}
>
Reload
{/* Stats grid */}
{jail.status && (
Currently banned:
{String(jail.status.currently_banned)}
Total banned:
{String(jail.status.total_banned)}
Currently failed:
{String(jail.status.currently_failed)}
Total failed:
{String(jail.status.total_failed)}
)}
{/* Config grid */}
Backend:
{jail.backend}
Find time:
{formatSeconds(jail.find_time)}
Ban time:
{formatSeconds(jail.ban_time)}
Max retry:
{String(jail.max_retry)}
{jail.date_pattern && (
<>
Date pattern:
{jail.date_pattern}
>
)}
{jail.log_encoding && (
<>
Log encoding:
{jail.log_encoding}
>
)}
);
}
// ---------------------------------------------------------------------------
// Sub-component: Patterns section
// ---------------------------------------------------------------------------
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
const sectionStyles = useCommonSectionStyles();
return (
Log Paths & Patterns
Log Paths
Fail Regex
Ignore Regex
{jail.actions.length > 0 && (
<>
Actions
>
)}
);
}
// ---------------------------------------------------------------------------
// Sub-component: Ban-time escalation section
// ---------------------------------------------------------------------------
function BantimeEscalationSection({ jail }: { jail: Jail }): React.JSX.Element | null {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const esc = jail.bantime_escalation;
if (!esc?.increment) return null;
return (
Ban-time Escalation
enabled
{esc.factor !== null && (
<>
Factor:
{String(esc.factor)}
>
)}
{esc.formula && (
<>
Formula:
{esc.formula}
>
)}
{esc.multipliers && (
<>
Multipliers:
{esc.multipliers}
>
)}
{esc.max_time !== null && (
<>
Max time:
{formatSeconds(esc.max_time)}
>
)}
{esc.rnd_time !== null && (
<>
Random jitter:
{formatSeconds(esc.rnd_time)}
>
)}
Count across all jails:
{esc.overall_jails ? "yes" : "no"}
);
}
// ---------------------------------------------------------------------------
// Sub-component: Ignore list section
// ---------------------------------------------------------------------------
interface IgnoreListSectionProps {
jailName: string;
ignoreList: string[];
ignoreSelf: boolean;
onAdd: (ip: string) => Promise;
onRemove: (ip: string) => Promise;
onToggleIgnoreSelf: (on: boolean) => Promise;
}
function IgnoreListSection({
jailName: _jailName,
ignoreList,
ignoreSelf,
onAdd,
onRemove,
onToggleIgnoreSelf,
}: IgnoreListSectionProps): React.JSX.Element {
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const [inputVal, setInputVal] = useState("");
const [opError, setOpError] = useState(null);
const handleAdd = (): void => {
if (!inputVal.trim()) return;
setOpError(null);
onAdd(inputVal.trim())
.then(() => {
setInputVal("");
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setOpError(msg);
});
};
const handleRemove = (ip: string): void => {
setOpError(null);
onRemove(ip).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setOpError(msg);
});
};
return (
Ignore List (IP Whitelist)
{String(ignoreList.length)}
{/* Ignore-self toggle */}
{
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setOpError(msg);
});
}}
/>
{opError && (
{opError}
)}
{/* Add form */}
{/* List */}
{ignoreList.length === 0 ? (
The ignore list is empty.
) : (
{ignoreList.map((ip) => (
{ip}
}
onClick={() => {
handleRemove(ip);
}}
aria-label={`Remove ${ip}`}
/>
))}
)}
);
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
/**
* Jail detail page.
*
* Fetches and displays the full configuration and state of a single jail
* identified by the `:name` route parameter.
*/
export function JailDetailPage(): React.JSX.Element {
const styles = useStyles();
const { name = "" } = useParams<{ name: string }>();
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } =
useJailDetail(name);
const {
items,
total,
page,
pageSize,
search,
loading: bannedLoading,
error: bannedError,
opError,
refresh: refreshBanned,
setPage,
setPageSize,
setSearch,
unban,
} = useJailBannedIps(name);
if (loading && !jail) {
return (
);
}
if (error) {
return (
}>
Back to Jails
Failed to load jail {name}: {error}
);
}
if (!jail) return <>>;
return (
{/* Breadcrumb */}
}>
Jails
/
{name}
);
}