refactor: move jail detail sub-sections from pages/jail to components/jail

Move reusable UI section components (JailInfoSection, PatternsSection,
BantimeEscalationSection, IgnoreListSection, CodeList) from pages/jail/
to components/jail/, aligning with the project convention that pages/
contains only route-level entry points while components/ contains reusable
UI building blocks.

Changes:
- Move 5 section components + jailDetailPageStyles.ts to components/jail/
- Update import paths in moved components (relative paths to commonStyles)
- Update JailDetailPage.tsx imports to reference components/jail/
- Delete empty pages/jail/ directory
- Document pages/ vs components/ distinction in Web-Development.md

All components use standard import structure and TypeScript passes type
checking. BannedIpsSection was already correctly placed in components/jail/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-25 19:17:03 +02:00
parent 8bd5713d38
commit 6a062a72a7
9 changed files with 25 additions and 33 deletions

View File

@@ -12,11 +12,11 @@ import { useJailData } from "../hooks/useJailData";
import { useJailCommands } from "../hooks/useJailCommands";
import { useJailBannedIps } from "../hooks/useJailBannedIps";
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";
import { JailInfoSection } from "../components/jail/JailInfoSection";
import { PatternsSection } from "../components/jail/PatternsSection";
import { BantimeEscalationSection } from "../components/jail/BantimeEscalationSection";
import { IgnoreListSection } from "../components/jail/IgnoreListSection";
import { useJailDetailPageStyles } from "../components/jail/jailDetailPageStyles";
export function JailDetailPage(): React.JSX.Element {
const styles = useJailDetailPageStyles();

View File

@@ -1,61 +0,0 @@
import { Badge, Text } from "@fluentui/react-components";
import { useCommonSectionStyles } from "../../components/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

@@ -1,24 +0,0 @@
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={`${item}-${String(i)}`} className={styles.codeItem}>
{item}
</span>
))}
</div>
);
}

View File

@@ -1,137 +0,0 @@
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 "../../components/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

@@ -1,146 +0,0 @@
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 "../../components/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

@@ -1,53 +0,0 @@
import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { useCommonSectionStyles } from "../../components/commonStyles";
import type { Jail } from "../../types/jail";
import { CodeList } from "./CodeList";
interface PatternsSectionProps {
jail: Jail;
}
const useStyles = makeStyles({
spacedHeading: {
marginTop: tokens.spacingVerticalS,
},
});
export function PatternsSection({ jail }: PatternsSectionProps): React.JSX.Element {
const sectionStyles = useCommonSectionStyles();
const styles = useStyles();
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" className={styles.spacedHeading}>
Fail Regex
</Text>
<CodeList items={jail.fail_regex} empty="No fail-regex patterns." />
<Text size={300} weight="semibold" className={styles.spacedHeading}>
Ignore Regex
</Text>
<CodeList items={jail.ignore_regex} empty="No ignore-regex patterns." />
{jail.actions.length > 0 && (
<>
<Text size={300} weight="semibold" className={styles.spacedHeading}>
Actions
</Text>
<CodeList items={jail.actions} empty="" />
</>
)}
</div>
);
}

View File

@@ -1,75 +0,0 @@
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 },
});