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:
2026-03-13 13:48:09 +01:00
parent a0e8566ff8
commit 9b73f6719d
23 changed files with 4275 additions and 1828 deletions

View 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>
&nbsp;
<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>
);
}