Task 1: Remove ActiveBansSection from JailsPage
- Delete buildBanColumns, fmtTimestamp, ActiveBansSection
- Remove Dialog/Delete/Dismiss imports, ActiveBan type
- Update JSDoc to reflect three sections
Task 2: Remove JailDistributionChart from Dashboard
- Delete import and JSX block from DashboardPage.tsx
Task 3: Fix transparent pie chart (TopCountriesPieChart)
- Add Cell import and per-slice <Cell fill={slice.fill}> children inside <Pie>
- Suppress @typescript-eslint/no-deprecated (recharts v3 types)
Task 4: Allow /config/log as safe log prefix
- Add '/config/log' to _SAFE_LOG_PREFIXES in config_service.py
- Update error message to list both allowed directories
Task 5: Block jail activation on missing filter/logpath
- activate_jail refuses to proceed when filter/logpath issues found
- ActivateJailDialog treats all validation issues as blocking
- Trigger immediate _run_probe after activation in config router
- /api/health now reports fail2ban online/offline from cached probe
- Add TestActivateJailBlocking tests; fix existing tests to mock validation
692 lines
21 KiB
TypeScript
692 lines
21 KiB
TypeScript
/**
|
|
* 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 { 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 { Link } 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`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Jail overview columns
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const jailColumns: TableColumnDefinition<JailSummary>[] = [
|
|
createTableColumn<JailSummary>({
|
|
columnId: "name",
|
|
renderHeaderCell: () => "Jail",
|
|
renderCell: (j) => (
|
|
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
|
|
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
|
{j.name}
|
|
</Text>
|
|
</Link>
|
|
),
|
|
}),
|
|
createTableColumn<JailSummary>({
|
|
columnId: "status",
|
|
renderHeaderCell: () => "Status",
|
|
renderCell: (j) => {
|
|
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
|
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
|
return <Badge appearance="filled" color="success">running</Badge>;
|
|
},
|
|
}),
|
|
createTableColumn<JailSummary>({
|
|
columnId: "backend",
|
|
renderHeaderCell: () => "Backend",
|
|
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
|
}),
|
|
createTableColumn<JailSummary>({
|
|
columnId: "banned",
|
|
renderHeaderCell: () => "Banned",
|
|
renderCell: (j) => (
|
|
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
|
),
|
|
}),
|
|
createTableColumn<JailSummary>({
|
|
columnId: "failed",
|
|
renderHeaderCell: () => "Failed",
|
|
renderCell: (j) => (
|
|
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
|
),
|
|
}),
|
|
createTableColumn<JailSummary>({
|
|
columnId: "findTime",
|
|
renderHeaderCell: () => "Find Time",
|
|
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
|
|
}),
|
|
createTableColumn<JailSummary>({
|
|
columnId: "banTime",
|
|
renderHeaderCell: () => "Ban Time",
|
|
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
|
|
}),
|
|
createTableColumn<JailSummary>({
|
|
columnId: "maxRetry",
|
|
renderHeaderCell: () => "Max Retry",
|
|
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
|
}),
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-component: Jail overview section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function JailOverviewSection(): React.JSX.Element {
|
|
const styles = useStyles();
|
|
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
|
useJails();
|
|
const [opError, setOpError] = useState<string | null>(null);
|
|
|
|
const handle = (fn: () => Promise<void>): void => {
|
|
setOpError(null);
|
|
fn().catch((err: unknown) => {
|
|
setOpError(err instanceof Error ? err.message : String(err));
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className={styles.section}>
|
|
<div className={styles.sectionHeader}>
|
|
<Text as="h2" size={500} weight="semibold">
|
|
Jail Overview
|
|
{total > 0 && (
|
|
<Badge appearance="tint" style={{ marginLeft: "8px" }}>
|
|
{String(total)}
|
|
</Badge>
|
|
)}
|
|
</Text>
|
|
<div className={styles.actionRow}>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<ArrowSyncRegular />}
|
|
onClick={() => { handle(reloadAll); }}
|
|
>
|
|
Reload All
|
|
</Button>
|
|
<Button size="small" appearance="subtle" icon={<ArrowClockwiseRegular />} onClick={refresh}>
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{opError && (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{opError}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
{error && (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>Failed to load jails: {error}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
{loading && jails.length === 0 ? (
|
|
<div className={styles.centred}>
|
|
<Spinner label="Loading jails…" />
|
|
</div>
|
|
) : (
|
|
<div className={styles.tableWrapper}>
|
|
<DataGrid
|
|
items={jails}
|
|
columns={jailColumns}
|
|
getRowId={(j: JailSummary) => j.name}
|
|
focusMode="composite"
|
|
>
|
|
<DataGridHeader>
|
|
<DataGridRow>
|
|
{({ renderHeaderCell }) => (
|
|
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
|
)}
|
|
</DataGridRow>
|
|
</DataGridHeader>
|
|
<DataGridBody<JailSummary>>
|
|
{({ item }) => (
|
|
<DataGridRow<JailSummary> key={item.name}>
|
|
{({ renderCell, columnId }) => {
|
|
if (columnId === "status") {
|
|
return (
|
|
<DataGridCell>
|
|
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
|
|
{renderCell(item)}
|
|
<Tooltip
|
|
content={item.running ? "Stop jail" : "Start jail"}
|
|
relationship="label"
|
|
>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={item.running ? <StopRegular /> : <PlayRegular />}
|
|
onClick={() => {
|
|
handle(async () => {
|
|
if (item.running) await stopJail(item.name);
|
|
else await startJail(item.name);
|
|
});
|
|
}}
|
|
aria-label={
|
|
item.running ? `Stop ${item.name}` : `Start ${item.name}`
|
|
}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip
|
|
content={item.idle ? "Resume from idle" : "Set idle"}
|
|
relationship="label"
|
|
>
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<PauseRegular />}
|
|
onClick={() => {
|
|
handle(async () => setIdle(item.name, !item.idle));
|
|
}}
|
|
disabled={!item.running}
|
|
aria-label={`Toggle idle for ${item.name}`}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip content="Reload jail" relationship="label">
|
|
<Button
|
|
size="small"
|
|
appearance="subtle"
|
|
icon={<ArrowSyncRegular />}
|
|
onClick={() => {
|
|
handle(async () => reloadJail(item.name));
|
|
}}
|
|
aria-label={`Reload ${item.name}`}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</DataGridCell>
|
|
);
|
|
}
|
|
return <DataGridCell>{renderCell(item)}</DataGridCell>;
|
|
}}
|
|
</DataGridRow>
|
|
)}
|
|
</DataGridBody>
|
|
</DataGrid>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-component: Ban / Unban IP form
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BanUnbanFormProps {
|
|
jailNames: string[];
|
|
onBan: (jail: string, ip: string) => Promise<void>;
|
|
onUnban: (ip: string, jail?: string) => Promise<void>;
|
|
}
|
|
|
|
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<string | null>(null);
|
|
const [formSuccess, setFormSuccess] = useState<string | null>(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 (
|
|
<div className={styles.section}>
|
|
<div className={styles.sectionHeader}>
|
|
<Text as="h2" size={500} weight="semibold">
|
|
Ban / Unban IP
|
|
</Text>
|
|
</div>
|
|
|
|
{formError && (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{formError}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
{formSuccess && (
|
|
<MessageBar intent="success">
|
|
<MessageBarBody>{formSuccess}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
{/* Ban row */}
|
|
<Text size={300} weight="semibold">
|
|
Ban an IP
|
|
</Text>
|
|
<div className={styles.formRow}>
|
|
<div className={styles.formField}>
|
|
<Field label="IP Address">
|
|
<Input
|
|
placeholder="e.g. 192.168.1.100"
|
|
value={banIpVal}
|
|
onChange={(_, d) => {
|
|
setBanIpVal(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className={styles.formField}>
|
|
<Field label="Jail">
|
|
<Select
|
|
value={banJail}
|
|
onChange={(_, d) => {
|
|
setBanJail(d.value);
|
|
}}
|
|
>
|
|
<option value="">Select jail…</option>
|
|
{jailNames.map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
</div>
|
|
<Button
|
|
appearance="primary"
|
|
icon={<LockClosedRegular />}
|
|
onClick={handleBan}
|
|
style={{ marginBottom: "4px" }}
|
|
>
|
|
Ban
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Unban row */}
|
|
<Text
|
|
size={300}
|
|
weight="semibold"
|
|
style={{ marginTop: tokens.spacingVerticalS }}
|
|
>
|
|
Unban an IP
|
|
</Text>
|
|
<div className={styles.formRow}>
|
|
<div className={styles.formField}>
|
|
<Field label="IP Address">
|
|
<Input
|
|
placeholder="e.g. 192.168.1.100"
|
|
value={unbanIpVal}
|
|
onChange={(_, d) => {
|
|
setUnbanIpVal(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className={styles.formField}>
|
|
<Field label="Jail (optional — leave blank for all)">
|
|
<Select
|
|
value={unbanJail}
|
|
onChange={(_, d) => {
|
|
setUnbanJail(d.value);
|
|
}}
|
|
>
|
|
<option value="">All jails</option>
|
|
{jailNames.map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
</div>
|
|
<Button
|
|
appearance="secondary"
|
|
icon={<LockOpenRegular />}
|
|
onClick={() => {
|
|
handleUnban(false);
|
|
}}
|
|
style={{ marginBottom: "4px" }}
|
|
>
|
|
Unban
|
|
</Button>
|
|
<Button
|
|
appearance="outline"
|
|
icon={<LockOpenRegular />}
|
|
onClick={() => {
|
|
handleUnban(true);
|
|
}}
|
|
style={{ marginBottom: "4px" }}
|
|
>
|
|
Unban from All Jails
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<div className={styles.section}>
|
|
<div className={styles.sectionHeader}>
|
|
<Text as="h2" size={500} weight="semibold">
|
|
IP Lookup
|
|
</Text>
|
|
</div>
|
|
|
|
<div className={styles.formRow}>
|
|
<div className={styles.formField}>
|
|
<Field label="IP Address">
|
|
<Input
|
|
placeholder="e.g. 1.2.3.4 or 2001:db8::1"
|
|
value={inputVal}
|
|
onChange={(_, d) => {
|
|
setInputVal(d.value);
|
|
clear();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleLookup();
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Button
|
|
appearance="primary"
|
|
icon={loading ? <Spinner size="tiny" /> : <SearchRegular />}
|
|
onClick={handleLookup}
|
|
disabled={loading || !inputVal.trim()}
|
|
style={{ marginBottom: "4px" }}
|
|
>
|
|
Look up
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{error}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
{result && (
|
|
<div className={styles.lookupResult}>
|
|
<div className={styles.lookupRow}>
|
|
<Text className={styles.lookupLabel}>IP:</Text>
|
|
<Text className={styles.mono}>{result.ip}</Text>
|
|
</div>
|
|
|
|
<div className={styles.lookupRow}>
|
|
<Text className={styles.lookupLabel}>Currently banned in:</Text>
|
|
{result.currently_banned_in.length === 0 ? (
|
|
<Badge appearance="tint" color="success">
|
|
not banned
|
|
</Badge>
|
|
) : (
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
|
{result.currently_banned_in.map((j) => (
|
|
<Badge key={j} appearance="filled" color="danger">
|
|
{j}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{result.geo && (
|
|
<>
|
|
{result.geo.country_name && (
|
|
<div className={styles.lookupRow}>
|
|
<Text className={styles.lookupLabel}>Country:</Text>
|
|
<Text>
|
|
{result.geo.country_name}
|
|
{result.geo.country_code ? ` (${result.geo.country_code})` : ""}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
{result.geo.org && (
|
|
<div className={styles.lookupRow}>
|
|
<Text className={styles.lookupLabel}>Organisation:</Text>
|
|
<Text>{result.geo.org}</Text>
|
|
</div>
|
|
)}
|
|
{result.geo.asn && (
|
|
<div className={styles.lookupRow}>
|
|
<Text className={styles.lookupLabel}>ASN:</Text>
|
|
<Text className={styles.mono}>{result.geo.asn}</Text>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<div className={styles.root}>
|
|
<Text as="h1" size={700} weight="semibold">
|
|
Jails
|
|
</Text>
|
|
|
|
<JailOverviewSection />
|
|
|
|
<BanUnbanForm jailNames={jailNames} onBan={banIp} onUnban={unbanIp} />
|
|
|
|
<IpLookupSection />
|
|
</div>
|
|
);
|
|
}
|