refactor(frontend): decompose ConfigPage into dedicated config components
- Extract tab components: JailsTab, ActionsTab, FiltersTab, JailFilesTab, GlobalTab, ServerTab, ConfFilesTab, RegexTesterTab, MapTab, ExportTab - Add form components: JailFileForm, ActionForm, FilterForm - Add AutoSaveIndicator, RegexList, configStyles, and barrel index - ConfigPage now composes these components; greatly reduces file size - Add tests: ConfigPage.test.tsx, useAutoSave.test.ts
This commit is contained in:
534
frontend/src/components/config/JailsTab.tsx
Normal file
534
frontend/src/components/config/JailsTab.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* JailsTab and JailAccordionPanel — per-jail configuration editor.
|
||||
*
|
||||
* Displays all active jails in an accordion. Each panel exposes editable
|
||||
* fields for ban time, find time, max retries, regex patterns, log paths,
|
||||
* date pattern, DNS mode, prefix regex, and ban-time escalation.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Switch,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowClockwise24Regular, Dismiss24Regular, LockClosed24Regular } from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
addLogPath,
|
||||
deleteLogPath,
|
||||
} from "../../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
} from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { useJailConfigs } from "../../hooks/useConfig";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { RegexList } from "./RegexList";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailAccordionPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailAccordionPanelProps {
|
||||
jail: JailConfig;
|
||||
onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable configuration panel for a single fail2ban jail.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
function JailAccordionPanel({
|
||||
jail,
|
||||
onSave,
|
||||
}: JailAccordionPanelProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [banTime, setBanTime] = useState(String(jail.ban_time));
|
||||
const [findTime, setFindTime] = useState(String(jail.find_time));
|
||||
const [maxRetry, setMaxRetry] = useState(String(jail.max_retry));
|
||||
const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex);
|
||||
const [ignoreRegex, setIgnoreRegex] = useState<string[]>(jail.ignore_regex);
|
||||
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
|
||||
const [datePattern, setDatePattern] = useState(jail.date_pattern ?? "");
|
||||
const [dnsMode, setDnsMode] = useState(jail.use_dns);
|
||||
const [prefRegex, setPrefRegex] = useState(jail.prefregex);
|
||||
const [deletingPath, setDeletingPath] = useState<string | null>(null);
|
||||
const [newLogPath, setNewLogPath] = useState("");
|
||||
const [newLogPathTail, setNewLogPathTail] = useState(true);
|
||||
const [addingLogPath, setAddingLogPath] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
// Ban-time escalation state
|
||||
const esc0 = jail.bantime_escalation;
|
||||
const [escEnabled, setEscEnabled] = useState(esc0?.increment ?? false);
|
||||
const [escFactor, setEscFactor] = useState(esc0?.factor != null ? String(esc0.factor) : "");
|
||||
const [escFormula, setEscFormula] = useState(esc0?.formula ?? "");
|
||||
const [escMultipliers, setEscMultipliers] = useState(esc0?.multipliers ?? "");
|
||||
const [escMaxTime, setEscMaxTime] = useState(esc0?.max_time != null ? String(esc0.max_time) : "");
|
||||
const [escRndTime, setEscRndTime] = useState(esc0?.rnd_time != null ? String(esc0.rnd_time) : "");
|
||||
const [escOverallJails, setEscOverallJails] = useState(esc0?.overall_jails ?? false);
|
||||
|
||||
const handleDeleteLogPath = useCallback(
|
||||
async (path: string) => {
|
||||
setDeletingPath(path);
|
||||
setMsg(null);
|
||||
try {
|
||||
await deleteLogPath(jail.name, path);
|
||||
setLogPaths((prev) => prev.filter((p) => p !== path));
|
||||
setMsg({ text: `Removed log path: ${path}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Delete failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setDeletingPath(null);
|
||||
}
|
||||
},
|
||||
[jail.name],
|
||||
);
|
||||
|
||||
const handleAddLogPath = useCallback(async () => {
|
||||
const trimmed = newLogPath.trim();
|
||||
if (!trimmed) return;
|
||||
setAddingLogPath(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail };
|
||||
await addLogPath(jail.name, req);
|
||||
setLogPaths((prev) => [...prev, trimmed]);
|
||||
setNewLogPath("");
|
||||
setMsg({ text: `Added log path: ${trimmed}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Failed to add log path.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setAddingLogPath(false);
|
||||
}
|
||||
}, [jail.name, newLogPath, newLogPathTail]);
|
||||
|
||||
const autoSavePayload = useMemo<JailConfigUpdate>(
|
||||
() => ({
|
||||
ban_time: Number(banTime) || jail.ban_time,
|
||||
find_time: Number(findTime) || jail.find_time,
|
||||
max_retry: Number(maxRetry) || jail.max_retry,
|
||||
fail_regex: failRegex,
|
||||
ignore_regex: ignoreRegex,
|
||||
date_pattern: datePattern !== "" ? datePattern : null,
|
||||
dns_mode: dnsMode,
|
||||
prefregex: prefRegex !== "" ? prefRegex : null,
|
||||
bantime_escalation: {
|
||||
increment: escEnabled,
|
||||
factor: escFactor !== "" ? Number(escFactor) : null,
|
||||
formula: escFormula !== "" ? escFormula : null,
|
||||
multipliers: escMultipliers !== "" ? escMultipliers : null,
|
||||
max_time: escMaxTime !== "" ? Number(escMaxTime) : null,
|
||||
rnd_time: escRndTime !== "" ? Number(escRndTime) : null,
|
||||
overall_jails: escOverallJails,
|
||||
},
|
||||
}),
|
||||
[
|
||||
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
|
||||
dnsMode, prefRegex, escEnabled, escFactor, escFormula, escMultipliers,
|
||||
escMaxTime, escRndTime, escOverallJails,
|
||||
jail.ban_time, jail.find_time, jail.max_retry,
|
||||
],
|
||||
);
|
||||
|
||||
const saveCurrent = useCallback(
|
||||
async (update: JailConfigUpdate): Promise<void> => {
|
||||
await onSave(jail.name, update);
|
||||
},
|
||||
[jail.name, onSave],
|
||||
);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(autoSavePayload, saveCurrent);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && (
|
||||
<MessageBar intent={msg.ok ? "success" : "error"}>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Ban Time (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={banTime}
|
||||
onChange={(_e, d) => {
|
||||
setBanTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Find Time (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={findTime}
|
||||
onChange={(_e, d) => {
|
||||
setFindTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max Retry">
|
||||
<Input
|
||||
type="number"
|
||||
value={maxRetry}
|
||||
onChange={(_e, d) => {
|
||||
setMaxRetry(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Backend">
|
||||
<Input readOnly value={jail.backend} />
|
||||
</Field>
|
||||
<Field label="Log Encoding">
|
||||
<Input readOnly value={jail.log_encoding} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Date Pattern" hint="Leave blank for auto-detect.">
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
placeholder="auto-detect"
|
||||
value={datePattern}
|
||||
onChange={(_e, d) => {
|
||||
setDatePattern(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DNS Mode">
|
||||
<Select
|
||||
value={dnsMode}
|
||||
onChange={(_e, d) => {
|
||||
setDnsMode(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="yes">yes — resolve hostnames</option>
|
||||
<option value="warn">warn — resolve and warn</option>
|
||||
<option value="no">no — skip hostname resolution</option>
|
||||
<option value="raw">raw — use value as-is</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Prefix Regex"
|
||||
hint="Prepended to every failregex for pre-filtering. Leave blank to disable."
|
||||
>
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
placeholder="e.g. ^%(__prefix_line)s"
|
||||
value={prefRegex}
|
||||
onChange={(_e, d) => {
|
||||
setPrefRegex(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Log Paths">
|
||||
{logPaths.length === 0 ? (
|
||||
<Text className={styles.infoText} size={200}>
|
||||
(none)
|
||||
</Text>
|
||||
) : (
|
||||
logPaths.map((p) => (
|
||||
<div key={p} className={styles.regexItem}>
|
||||
<span className={styles.codeFont} style={{ flexGrow: 1 }}>
|
||||
{p}
|
||||
</span>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<Dismiss24Regular />}
|
||||
size="small"
|
||||
disabled={deletingPath === p}
|
||||
title="Remove log path"
|
||||
onClick={() => void handleDeleteLogPath(p)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{/* Add log path inline form */}
|
||||
<div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}>
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
style={{ flexGrow: 1 }}
|
||||
placeholder="/var/log/example.log"
|
||||
value={newLogPath}
|
||||
disabled={addingLogPath}
|
||||
aria-label="New log path"
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPath(d.value);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={newLogPathTail ? "tail" : "head"}
|
||||
checked={newLogPathTail}
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPathTail(d.checked);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
aria-label="Add log path"
|
||||
disabled={addingLogPath || !newLogPath.trim()}
|
||||
onClick={() => void handleAddLogPath()}
|
||||
>
|
||||
{addingLogPath ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<RegexList
|
||||
label="Fail Regex"
|
||||
patterns={failRegex}
|
||||
onChange={setFailRegex}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<RegexList
|
||||
label="Ignore Regex"
|
||||
patterns={ignoreRegex}
|
||||
onChange={setIgnoreRegex}
|
||||
/>
|
||||
</div>
|
||||
{jail.actions.length > 0 && (
|
||||
<Field label="Actions">
|
||||
<div>
|
||||
{jail.actions.map((a) => (
|
||||
<Badge
|
||||
key={a}
|
||||
appearance="tint"
|
||||
color="informative"
|
||||
style={{ marginRight: tokens.spacingHorizontalXS }}
|
||||
>
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Ban-time Escalation */}
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<Text weight="semibold" size={400} block>
|
||||
Ban-time Escalation
|
||||
</Text>
|
||||
<Switch
|
||||
label="Enable incremental banning"
|
||||
checked={escEnabled}
|
||||
onChange={(_e, d) => {
|
||||
setEscEnabled(d.checked);
|
||||
}}
|
||||
/>
|
||||
{escEnabled && (
|
||||
<div>
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Factor">
|
||||
<Input
|
||||
type="number"
|
||||
value={escFactor}
|
||||
onChange={(_e, d) => {
|
||||
setEscFactor(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max Time (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={escMaxTime}
|
||||
onChange={(_e, d) => {
|
||||
setEscMaxTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Random Jitter (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={escRndTime}
|
||||
onChange={(_e, d) => {
|
||||
setEscRndTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Formula">
|
||||
<Input
|
||||
value={escFormula}
|
||||
onChange={(_e, d) => {
|
||||
setEscFormula(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Multipliers (space-separated)">
|
||||
<Input
|
||||
value={escMultipliers}
|
||||
onChange={(_e, d) => {
|
||||
setEscMultipliers(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Switch
|
||||
label="Count repeat offences across all jails"
|
||||
checked={escOverallJails}
|
||||
onChange={(_e, d) => {
|
||||
setEscOverallJails(d.checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailsTab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tab component showing all active fail2ban jails with editable configs.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function JailsTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { jails, loading, error, refresh, updateJail, reloadAll } =
|
||||
useJailConfigs();
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [reloadMsg, setReloadMsg] = useState<string | null>(null);
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
setReloading(true);
|
||||
setReloadMsg(null);
|
||||
try {
|
||||
await reloadAll();
|
||||
setReloadMsg("fail2ban reloaded.");
|
||||
} catch (err: unknown) {
|
||||
setReloadMsg(
|
||||
err instanceof ApiError ? err.message : "Reload failed.",
|
||||
);
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}, [reloadAll]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading jail configs…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
||||
))}
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={refresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
disabled={reloading}
|
||||
onClick={() => void handleReload()}
|
||||
>
|
||||
{reloading ? "Reloading…" : "Reload fail2ban"}
|
||||
</Button>
|
||||
</div>
|
||||
{reloadMsg && (
|
||||
<MessageBar style={{ marginTop: tokens.spacingVerticalS }} intent="info">
|
||||
<MessageBarBody>{reloadMsg}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{jails.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
<LockClosed24Regular
|
||||
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No active jails found.
|
||||
</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Ensure fail2ban is running and jails are configured.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{jails.map((jail) => (
|
||||
<AccordionItem key={jail.name} value={jail.name} className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
<Text weight="semibold">{jail.name}</Text>
|
||||
|
||||
<Badge
|
||||
appearance="tint"
|
||||
color="informative"
|
||||
style={{ marginLeft: tokens.spacingHorizontalS }}
|
||||
>
|
||||
ban: {jail.ban_time}s
|
||||
</Badge>
|
||||
<Badge
|
||||
appearance="tint"
|
||||
color="subtle"
|
||||
style={{ marginLeft: tokens.spacingHorizontalXS }}
|
||||
>
|
||||
retries: {jail.max_retry}
|
||||
</Badge>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<JailAccordionPanel jail={jail} onSave={updateJail} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user