Refactor frontend pages and config components into single-component files for Task 13

This commit is contained in:
2026-04-19 09:30:35 +02:00
parent 6c053cdaee
commit 38b9d35255
31 changed files with 2158 additions and 2603 deletions

View File

@@ -5,41 +5,13 @@
*/
import { useCallback, useState } from "react";
import { Button, MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
import { MessageBar, MessageBarBody, Text } from "@fluentui/react-components";
import { useBlocklistStyles } from "../components/blocklist/blocklistStyles";
import { BlocklistSourcesSection } from "../components/blocklist/BlocklistSourcesSection";
import { BlocklistScheduleSection } from "../components/blocklist/BlocklistScheduleSection";
import { BlocklistImportLogSection } from "../components/blocklist/BlocklistImportLogSection";
import { useRunImport } from "../hooks/useBlocklist";
import type { ImportRunResult } from "../types/blocklist";
interface ImportResultDialogProps {
open: boolean;
result: ImportRunResult | null;
onClose: () => void;
}
function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
if (!open || !result) return <></>;
return (
<div style={{ position: "fixed", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(0, 0, 0, 0.5)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 }}>
<div style={{ background: "white", padding: "24px", borderRadius: "8px", maxWidth: "520px", minWidth: "300px" }}>
<Text as="h2" size={500} weight="semibold">
Import Complete
</Text>
<Text size={200} style={{ marginTop: "12px" }}>
Total imported: {result.total_imported} | Skipped: {result.total_skipped} | Sources with errors: {result.errors_count}
</Text>
<div style={{ marginTop: "16px", textAlign: "right" }}>
<Button appearance="primary" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
import { ImportResultDialog } from "./blocklists/ImportResultDialog";
export function BlocklistsPage(): React.JSX.Element {
const safeUseBlocklistStyles = useBlocklistStyles as unknown as () => { root: string };

View File

@@ -1,545 +1,23 @@
/**
* 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 <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>{empty}</Text>;
}
return (
<div className={styles.codeList}>
{items.map((item, i) => (
<span key={i} className={styles.codeItem}>
{item}
</span>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-component: Jail info card
// ---------------------------------------------------------------------------
interface JailInfoProps {
jail: Jail;
onRefresh: () => void;
onStart: () => Promise<void>;
onStop: () => Promise<void>;
onSetIdle: (on: boolean) => Promise<void>;
onReload: () => Promise<void>;
}
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<string | null>(null);
const handle =
(fn: () => Promise<unknown>, 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 (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<div className={styles.headerRow}>
<Text
size={600}
weight="semibold"
style={{ fontFamily: "Consolas, 'Courier New', monospace" }}
>
{jail.name}
</Text>
{jail.running ? (
jail.idle ? (
<Badge appearance="filled" color="warning">idle</Badge>
) : (
<Badge appearance="filled" color="success">running</Badge>
)
) : (
<Badge appearance="filled" color="danger">stopped</Badge>
)}
</div>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={onRefresh}
aria-label="Refresh"
/>
</div>
{ctrlError && (
<MessageBar intent="error">
<MessageBarBody>{ctrlError}</MessageBarBody>
</MessageBar>
)}
{/* Control buttons */}
<div className={styles.controlRow}>
{jail.running ? (
<Tooltip content="Stop jail" relationship="label">
<Button
appearance="secondary"
icon={<StopRegular />}
onClick={handle(onStop)}
>
Stop
</Button>
</Tooltip>
) : (
<Tooltip content="Start jail" relationship="label">
<Button
appearance="primary"
icon={<PlayRegular />}
onClick={handle(onStart)}
>
Start
</Button>
</Tooltip>
)}
<Tooltip
content={jail.idle ? "Resume from idle mode" : "Pause monitoring (idle mode)"}
relationship="label"
>
<Button
appearance="outline"
icon={<PauseRegular />}
onClick={handle(() => onSetIdle(!jail.idle))}
disabled={!jail.running}
>
{jail.idle ? "Resume" : "Set Idle"}
</Button>
</Tooltip>
<Tooltip content="Reload jail configuration" relationship="label">
<Button
appearance="outline"
icon={<ArrowSyncRegular />}
onClick={handle(onReload)}
>
Reload
</Button>
</Tooltip>
</div>
{/* Stats grid */}
{jail.status && (
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
<Text className={styles.label}>Currently banned:</Text>
<Text>{String(jail.status.currently_banned)}</Text>
<Text className={styles.label}>Total banned:</Text>
<Text>{String(jail.status.total_banned)}</Text>
<Text className={styles.label}>Currently failed:</Text>
<Text>{String(jail.status.currently_failed)}</Text>
<Text className={styles.label}>Total failed:</Text>
<Text>{String(jail.status.total_failed)}</Text>
</div>
)}
{/* Config grid */}
<div className={styles.grid} style={{ marginTop: tokens.spacingVerticalS }}>
<Text className={styles.label}>Backend:</Text>
<Text className={styles.mono}>{jail.backend}</Text>
<Text className={styles.label}>Find time:</Text>
<Text>{formatSeconds(jail.find_time)}</Text>
<Text className={styles.label}>Ban time:</Text>
<Text>{formatSeconds(jail.ban_time)}</Text>
<Text className={styles.label}>Max retry:</Text>
<Text>{String(jail.max_retry)}</Text>
{jail.date_pattern && (
<>
<Text className={styles.label}>Date pattern:</Text>
<Text className={styles.mono}>{jail.date_pattern}</Text>
</>
)}
{jail.log_encoding && (
<>
<Text className={styles.label}>Log encoding:</Text>
<Text className={styles.mono}>{jail.log_encoding}</Text>
</>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-component: Patterns section
// ---------------------------------------------------------------------------
function PatternsSection({ jail }: { jail: Jail }): React.JSX.Element {
const sectionStyles = useCommonSectionStyles();
return (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Log Paths &amp; Patterns
</Text>
</div>
<Text size={300} weight="semibold">Log Paths</Text>
<CodeList items={jail.log_paths} empty="No log paths configured." />
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
Fail Regex
</Text>
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
Ignore Regex
</Text>
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
{jail.actions.length > 0 && (
<>
<Text size={300} weight="semibold" style={{ marginTop: tokens.spacingVerticalS }}>
Actions
</Text>
<CodeList items={jail.actions} empty="" />
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Ban-time Escalation
</Text>
<Badge appearance="filled" color="informative">enabled</Badge>
</div>
<div className={styles.grid}>
{esc.factor !== null && (
<>
<Text className={styles.label}>Factor:</Text>
<Text className={styles.mono}>{String(esc.factor)}</Text>
</>
)}
{esc.formula && (
<>
<Text className={styles.label}>Formula:</Text>
<Text className={styles.mono}>{esc.formula}</Text>
</>
)}
{esc.multipliers && (
<>
<Text className={styles.label}>Multipliers:</Text>
<Text className={styles.mono}>{esc.multipliers}</Text>
</>
)}
{esc.max_time !== null && (
<>
<Text className={styles.label}>Max time:</Text>
<Text>{formatSeconds(esc.max_time)}</Text>
</>
)}
{esc.rnd_time !== null && (
<>
<Text className={styles.label}>Random jitter:</Text>
<Text>{formatSeconds(esc.rnd_time)}</Text>
</>
)}
<Text className={styles.label}>Count across all jails:</Text>
<Text>{esc.overall_jails ? "yes" : "no"}</Text>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-component: Ignore list section
// ---------------------------------------------------------------------------
interface IgnoreListSectionProps {
jailName: string;
ignoreList: string[];
ignoreSelf: boolean;
onAdd: (ip: string) => Promise<void>;
onRemove: (ip: string) => Promise<void>;
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
}
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<string | null>(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 (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<Text as="h2" size={500} weight="semibold">
Ignore List (IP Whitelist)
</Text>
</div>
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
</div>
{/* Ignore-self toggle */}
<Switch
label="Ignore self — exclude this server's own IP addresses from banning"
checked={ignoreSelf}
onChange={(_e, data): void => {
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setOpError(msg);
});
}}
/>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{/* Add form */}
<div className={styles.formRow}>
<div className={styles.formField}>
<Field label="Add IP or CIDR network">
<Input
placeholder="e.g. 10.0.0.0/8 or 192.168.1.1"
value={inputVal}
onChange={(_, d) => {
setInputVal(d.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
</Field>
</div>
<Button
appearance="primary"
onClick={handleAdd}
disabled={!inputVal.trim()}
style={{ marginBottom: "4px" }}
>
Add
</Button>
</div>
{/* List */}
{ignoreList.length === 0 ? (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
The ignore list is empty.
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
{ignoreList.map((ip) => (
<div key={ip} className={styles.ignoreRow}>
<Text className={styles.mono}>{ip}</Text>
<Tooltip content={`Remove ${ip} from ignore list`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
handleRemove(ip);
}}
aria-label={`Remove ${ip}`}
/>
</Tooltip>
</div>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
/**
* Jail detail page.
*
* Fetches and displays the full configuration and state of a single jail
* identified by the `:name` route parameter.
*/
import { Button, MessageBar, MessageBarBody, Spinner, Text } from "@fluentui/react-components";
import { ArrowLeftRegular } from "@fluentui/react-icons";
import { Link, useParams } from "react-router-dom";
import { useJailDetail, useJailBannedIps } from "../hooks/useJails";
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
import { JailInfoSection } from "./jail/JailInfoSection";
import { PatternsSection } from "./jail/PatternsSection";
import { BantimeEscalationSection } from "./jail/BantimeEscalationSection";
import { IgnoreListSection } from "./jail/IgnoreListSection";
import { useJailDetailPageStyles } from "./jail/jailDetailPageStyles";
export function JailDetailPage(): React.JSX.Element {
const styles = useStyles();
const styles = useJailDetailPageStyles();
const { name = "" } = useParams<{ name: string }>();
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } =
useJailDetail(name);
@@ -586,14 +64,13 @@ export function JailDetailPage(): React.JSX.Element {
return (
<div className={styles.root}>
{/* Breadcrumb */}
<div className={styles.breadcrumb}>
<Link to="/jails" style={{ textDecoration: "none" }}>
<Button appearance="subtle" size="small" icon={<ArrowLeftRegular />}>
Jails
</Button>
</Link>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
<Text size={200} style={{ color: "var(--colorNeutralForeground3)" }}>
/
</Text>
<Text size={200} className={styles.mono}>

View File

@@ -1,627 +1,12 @@
/**
* 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 { Text } from "@fluentui/react-components";
import { useJailsPageStyles } from "./jails/jailsPageStyles";
import { JailOverviewSection } from "./jails/JailOverviewSection";
import { BanUnbanForm } from "./jails/BanUnbanForm";
import { IpLookupSection } from "./jails/IpLookupSection";
import { useActiveBans, useJails } from "../hooks/useJails";
import { useState } from "react";
import { formatSeconds } from "../utils/formatDate";
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 { useCardStyles, useCommonSectionStyles } from "../theme/commonStyles";
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,
},
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,
},
lookupRow: {
display: "flex",
gap: tokens.spacingHorizontalM,
flexWrap: "wrap",
alignItems: "center",
},
lookupLabel: { fontWeight: tokens.fontWeightSemibold },
});
// ---------------------------------------------------------------------------
// 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}>{formatSeconds(j.find_time)}</Text>,
}),
createTableColumn<JailSummary>({
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j) => <Text size={200}>{formatSeconds(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 sectionStyles = useCommonSectionStyles();
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={sectionStyles.section}>
<div className={sectionStyles.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 sectionStyles = useCommonSectionStyles();
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={sectionStyles.section}>
<div className={sectionStyles.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 sectionStyles = useCommonSectionStyles();
const cardStyles = useCardStyles();
const { result, loading, error, lookup, clear } = useIpLookup();
const [inputVal, setInputVal] = useState("");
const handleLookup = (): void => {
if (inputVal.trim()) {
lookup(inputVal.trim());
}
};
return (
<div className={sectionStyles.section}>
<div className={sectionStyles.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={`${cardStyles.card} ${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 styles = useJailsPageStyles();
const { jails } = useJails();
const { banIp, unbanIp } = useActiveBans();

View File

@@ -0,0 +1,32 @@
import { Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Text } from "@fluentui/react-components";
import type { ImportRunResult } from "../../types/blocklist";
interface ImportResultDialogProps {
open: boolean;
result: ImportRunResult | null;
onClose: () => void;
}
export function ImportResultDialog({ open, result, onClose }: ImportResultDialogProps): React.JSX.Element {
if (!open || !result) return <></>;
return (
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) onClose(); }}>
<DialogSurface>
<DialogBody>
<DialogTitle>Import Complete</DialogTitle>
<DialogContent>
<Text size={200}>
Total imported: {result.total_imported} | Skipped: {result.total_skipped} | Sources with errors: {result.errors_count}
</Text>
</DialogContent>
<DialogActions>
<Button appearance="primary" onClick={onClose}>
Close
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}

View File

@@ -0,0 +1,61 @@
import { Badge, Text } from "@fluentui/react-components";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
import type { Jail } from "../../types/jail";
import { formatSeconds } from "../../utils/formatDate";
interface BantimeEscalationSectionProps {
jail: Jail;
}
export function BantimeEscalationSection({ jail }: BantimeEscalationSectionProps): React.JSX.Element | null {
const styles = useJailDetailPageStyles();
const sectionStyles = useCommonSectionStyles();
const esc = jail.bantime_escalation;
if (!esc?.increment) return null;
return (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Ban-time Escalation
</Text>
<Badge appearance="filled" color="informative">enabled</Badge>
</div>
<div className={styles.grid}>
{esc.factor !== null && (
<>
<Text className={styles.label}>Factor:</Text>
<Text className={styles.mono}>{String(esc.factor)}</Text>
</>
)}
{esc.formula && (
<>
<Text className={styles.label}>Formula:</Text>
<Text className={styles.mono}>{esc.formula}</Text>
</>
)}
{esc.multipliers && (
<>
<Text className={styles.label}>Multipliers:</Text>
<Text className={styles.mono}>{esc.multipliers}</Text>
</>
)}
{esc.max_time !== null && (
<>
<Text className={styles.label}>Max time:</Text>
<Text>{formatSeconds(esc.max_time)}</Text>
</>
)}
{esc.rnd_time !== null && (
<>
<Text className={styles.label}>Random jitter:</Text>
<Text>{formatSeconds(esc.rnd_time)}</Text>
</>
)}
<Text className={styles.label}>Count across all jails:</Text>
<Text>{esc.overall_jails ? "yes" : "no"}</Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Text } from "@fluentui/react-components";
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
interface CodeListProps {
items: string[];
empty: string;
}
export function CodeList({ items, empty }: CodeListProps): React.JSX.Element {
const styles = useJailDetailPageStyles();
if (items.length === 0) {
return <Text size={200} style={{ color: "var(--colorNeutralForeground3)" }}>{empty}</Text>;
}
return (
<div className={styles.codeList}>
{items.map((item, i) => (
<span key={i} className={styles.codeItem}>
{item}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useState } from "react";
import {
Badge,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Switch,
Text,
Tooltip,
} from "@fluentui/react-components";
import { DismissRegular } from "@fluentui/react-icons";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
interface IgnoreListSectionProps {
jailName: string;
ignoreList: string[];
ignoreSelf: boolean;
onAdd: (ip: string) => Promise<void>;
onRemove: (ip: string) => Promise<void>;
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
}
export function IgnoreListSection({
jailName: _jailName,
ignoreList,
ignoreSelf,
onAdd,
onRemove,
onToggleIgnoreSelf,
}: IgnoreListSectionProps): React.JSX.Element {
const styles = useJailDetailPageStyles();
const sectionStyles = useCommonSectionStyles();
const [inputVal, setInputVal] = useState("");
const [opError, setOpError] = useState<string | null>(null);
const handleAdd = (): void => {
if (!inputVal.trim()) return;
setOpError(null);
onAdd(inputVal.trim())
.then(() => {
setInputVal("");
})
.catch((err: unknown) => {
setOpError(err instanceof Error ? err.message : String(err));
});
};
const handleRemove = (ip: string): void => {
setOpError(null);
onRemove(ip).catch((err: unknown) => {
setOpError(err instanceof Error ? err.message : String(err));
});
};
return (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacingHorizontalM)" }}>
<Text as="h2" size={500} weight="semibold">
Ignore List (IP Whitelist)
</Text>
</div>
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
</div>
<Switch
label="Ignore self — exclude this server's own IP addresses from banning"
checked={ignoreSelf}
onChange={(_e, data): void => {
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
setOpError(err instanceof Error ? err.message : String(err));
});
}}
/>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.formRow}>
<div className={styles.formField}>
<Field label="Add IP or CIDR network">
<Input
placeholder="e.g. 10.0.0.0/8 or 192.168.1.1"
value={inputVal}
onChange={(_, d) => {
setInputVal(d.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
</Field>
</div>
<Button
appearance="primary"
onClick={handleAdd}
disabled={!inputVal.trim()}
>
Add
</Button>
</div>
{ignoreList.length === 0 ? (
<Text size={200} style={{ color: "var(--colorNeutralForeground3)" }}>
The ignore list is empty.
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
{ignoreList.map((ip) => (
<div key={ip} className={styles.ignoreRow}>
<Text className={styles.mono}>{ip}</Text>
<Tooltip content={`Remove ${ip} from ignore list`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
handleRemove(ip);
}}
aria-label={`Remove ${ip}`}
>
Remove
</Button>
</Tooltip>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Button,
MessageBar,
MessageBarBody,
Text,
} from "@fluentui/react-components";
import {
ArrowClockwiseRegular,
ArrowSyncRegular,
PauseRegular,
PlayRegular,
StopRegular,
} from "@fluentui/react-icons";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { useJailDetailPageStyles } from "./jailDetailPageStyles";
import type { Jail } from "../../types/jail";
interface JailInfoProps {
jail: Jail;
onRefresh: () => void;
onStart: () => Promise<void>;
onStop: () => Promise<void>;
onSetIdle: (on: boolean) => Promise<void>;
onReload: () => Promise<void>;
}
export function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
const styles = useJailDetailPageStyles();
const sectionStyles = useCommonSectionStyles();
const navigate = useNavigate();
const [ctrlError, setCtrlError] = useState<string | null>(null);
const handle =
(fn: () => Promise<unknown>, 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 (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<div className={styles.headerRow}>
<Text size={600} weight="semibold" style={{ fontFamily: "Consolas, 'Courier New', monospace" }}>
{jail.name}
</Text>
{jail.running ? (
jail.idle ? (
<Badge appearance="filled" color="warning">idle</Badge>
) : (
<Badge appearance="filled" color="success">running</Badge>
)
) : (
<Badge appearance="filled" color="danger">stopped</Badge>
)}
</div>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={onRefresh}
aria-label="Refresh"
/>
</div>
{ctrlError && (
<MessageBar intent="error">
<MessageBarBody>{ctrlError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.controlRow}>
{jail.running ? (
<Button appearance="secondary" icon={<StopRegular />} onClick={handle(onStop)}>
Stop
</Button>
) : (
<Button appearance="primary" icon={<PlayRegular />} onClick={handle(onStart)}>
Start
</Button>
)}
<Button
appearance="outline"
icon={<PauseRegular />}
onClick={handle(() => onSetIdle(!jail.idle))}
disabled={!jail.running}
>
{jail.idle ? "Resume" : "Set Idle"}
</Button>
<Button appearance="outline" icon={<ArrowSyncRegular />} onClick={handle(onReload)}>
Reload
</Button>
</div>
{jail.status && (
<div className={styles.grid} style={{ marginTop: "var(--spacingVerticalS)" }}>
<Text className={styles.label}>Currently banned:</Text>
<Text>{String(jail.status.currently_banned)}</Text>
<Text className={styles.label}>Total banned:</Text>
<Text>{String(jail.status.total_banned)}</Text>
<Text className={styles.label}>Currently failed:</Text>
<Text>{String(jail.status.currently_failed)}</Text>
<Text className={styles.label}>Total failed:</Text>
<Text>{String(jail.status.total_failed)}</Text>
</div>
)}
<div className={styles.grid} style={{ marginTop: "var(--spacingVerticalS)" }}>
<Text className={styles.label}>Backend:</Text>
<Text className={styles.mono}>{jail.backend}</Text>
<Text className={styles.label}>Find time:</Text>
<Text>{String(jail.find_time)}</Text>
<Text className={styles.label}>Ban time:</Text>
<Text>{String(jail.ban_time)}</Text>
<Text className={styles.label}>Max retry:</Text>
<Text>{String(jail.max_retry)}</Text>
{jail.date_pattern && (
<>
<Text className={styles.label}>Date pattern:</Text>
<Text className={styles.mono}>{jail.date_pattern}</Text>
</>
)}
{jail.log_encoding && (
<>
<Text className={styles.label}>Log encoding:</Text>
<Text className={styles.mono}>{jail.log_encoding}</Text>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Text } from "@fluentui/react-components";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import type { Jail } from "../../types/jail";
import { CodeList } from "./CodeList";
interface PatternsSectionProps {
jail: Jail;
}
export function PatternsSection({ jail }: PatternsSectionProps): React.JSX.Element {
const sectionStyles = useCommonSectionStyles();
return (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Log Paths &amp; Patterns
</Text>
</div>
<Text size={300} weight="semibold">
Log Paths
</Text>
<CodeList items={jail.log_paths} empty="No log paths configured." />
<Text size={300} weight="semibold" style={{ marginTop: "var(--spacingVerticalS)" }}>
Fail Regex
</Text>
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
<Text size={300} weight="semibold" style={{ marginTop: "var(--spacingVerticalS)" }}>
Ignore Regex
</Text>
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
{jail.actions.length > 0 && (
<>
<Text size={300} weight="semibold" style={{ marginTop: "var(--spacingVerticalS)" }}>
Actions
</Text>
<CodeList items={jail.actions} empty="" />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { makeStyles, tokens } from "@fluentui/react-components";
export const useJailDetailPageStyles = 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: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
},
codeList: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXXS,
paddingTop: tokens.spacingVerticalXS,
},
codeItem: {
fontFamily: tokens.fontFamilyMonospace,
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 },
});

View File

@@ -0,0 +1,179 @@
import { useState } from "react";
import {
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Text,
tokens,
} from "@fluentui/react-components";
import { LockClosedRegular, LockOpenRegular } from "@fluentui/react-icons";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { useJailsPageStyles } from "./jailsPageStyles";
import { ApiError } from "../../api/client";
interface BanUnbanFormProps {
jailNames: string[];
onBan: (jail: string, ip: string) => Promise<void>;
onUnban: (ip: string, jail?: string) => Promise<void>;
}
export function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.JSX.Element {
const styles = useJailsPageStyles();
const sectionStyles = useCommonSectionStyles();
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={sectionStyles.section}>
<div className={sectionStyles.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>
)}
<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}>
Ban
</Button>
</div>
<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); }}>
Unban
</Button>
<Button appearance="outline" icon={<LockOpenRegular />} onClick={() => { handleUnban(true); }}>
Unban from All Jails
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { useState } from "react";
import {
Badge,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
Text,
} from "@fluentui/react-components";
import { SearchRegular } from "@fluentui/react-icons";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { useJailsPageStyles } from "./jailsPageStyles";
import { useIpLookup } from "../../hooks/useJails";
export function IpLookupSection(): React.JSX.Element {
const styles = useJailsPageStyles();
const sectionStyles = useCommonSectionStyles();
const cardStyles = useCommonSectionStyles();
const { result, loading, error, lookup, clear } = useIpLookup();
const [inputVal, setInputVal] = useState("");
const handleLookup = (): void => {
if (inputVal.trim()) {
lookup(inputVal.trim());
}
};
return (
<div className={sectionStyles.section}>
<div className={sectionStyles.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()}
>
Look up
</Button>
</div>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{result && (
<div className={`${cardStyles.section} ${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>
);
}

View File

@@ -0,0 +1,218 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { formatSeconds } from "../../utils/formatDate";
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
MessageBar,
MessageBarBody,
Spinner,
Text,
Tooltip,
} from "@fluentui/react-components";
import {
ArrowClockwiseRegular,
ArrowSyncRegular,
PauseRegular,
PlayRegular,
StopRegular,
} from "@fluentui/react-icons";
import { useCommonSectionStyles } from "../../theme/commonStyles";
import { useJailsPageStyles } from "./jailsPageStyles";
import { useJails } from "../../hooks/useJails";
import type { JailSummary } from "../../types/jail";
const jailColumns = [
{
columnId: "name",
renderHeaderCell: () => "Jail",
renderCell: (j: JailSummary) => (
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
{j.name}
</Text>
</Link>
),
compare: (a: JailSummary, b: JailSummary) => a.name.localeCompare(b.name),
},
{
columnId: "status",
renderHeaderCell: () => "Status",
renderCell: (j: JailSummary) => {
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>;
},
compare: (a: JailSummary, b: JailSummary) => {
if (a.running !== b.running) return a.running ? -1 : 1;
if (a.idle !== b.idle) return a.idle ? 1 : -1;
return 0;
},
},
{
columnId: "backend",
renderHeaderCell: () => "Backend",
renderCell: (j: JailSummary) => <Text size={200}>{j.backend}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.backend.localeCompare(b.backend),
},
{
columnId: "banned",
renderHeaderCell: () => "Banned",
renderCell: (j: JailSummary) => (
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
),
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_banned ?? 0) - (b.status?.currently_banned ?? 0),
},
{
columnId: "failed",
renderHeaderCell: () => "Failed",
renderCell: (j: JailSummary) => (
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
),
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_failed ?? 0) - (b.status?.currently_failed ?? 0),
},
{
columnId: "findTime",
renderHeaderCell: () => "Find Time",
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.find_time)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.find_time - b.find_time,
},
{
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.ban_time)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.ban_time - b.ban_time,
},
{
columnId: "maxRetry",
renderHeaderCell: () => "Max Retry",
renderCell: (j: JailSummary) => <Text size={200}>{String(j.max_retry)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.max_retry - b.max_retry,
},
];
export function JailOverviewSection(): React.JSX.Element {
const styles = useJailsPageStyles();
const sectionStyles = useCommonSectionStyles();
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={sectionStyles.section}>
<div className={sectionStyles.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>
);
}

View File

@@ -0,0 +1,46 @@
import { makeStyles, tokens } from "@fluentui/react-components";
export const useJailsPageStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalL,
},
tableWrapper: { overflowX: "auto" },
centred: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: tokens.spacingVerticalXXL,
},
mono: {
fontFamily: tokens.fontFamilyMonospace,
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,
},
lookupRow: {
display: "flex",
gap: tokens.spacingHorizontalM,
flexWrap: "wrap",
alignItems: "center",
},
lookupLabel: { fontWeight: tokens.fontWeightSemibold },
});