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,194 @@
/**
* ServerTab — fail2ban server-level settings editor.
*
* Provides form fields for live server settings (log level, log target,
* DB purge age, DB max matches) and a "Flush Logs" action button.
*/
import { useCallback, useMemo, useState } from "react";
import {
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Skeleton,
SkeletonItem,
tokens,
} from "@fluentui/react-components";
import {
DocumentArrowDown24Regular,
} from "@fluentui/react-icons";
import { ApiError } from "../../api/client";
import type { ServerSettingsUpdate } from "../../types/config";
import { useServerSettings } from "../../hooks/useConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
/** Available fail2ban log levels in descending severity order. */
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
/**
* Tab component for editing live fail2ban server settings.
*
* @returns JSX element.
*/
export function ServerTab(): React.JSX.Element {
const styles = useConfigStyles();
const { settings, loading, error, updateSettings, flush } =
useServerSettings();
const [logLevel, setLogLevel] = useState("");
const [logTarget, setLogTarget] = useState("");
const [dbPurgeAge, setDbPurgeAge] = useState("");
const [dbMaxMatches, setDbMaxMatches] = useState("");
const [flushing, setFlushing] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const effectiveLogLevel = logLevel || settings?.log_level || "";
const effectiveLogTarget = logTarget || settings?.log_target || "";
const effectiveDbPurgeAge =
dbPurgeAge || (settings ? String(settings.db_purge_age) : "");
const effectiveDbMaxMatches =
dbMaxMatches || (settings ? String(settings.db_max_matches) : "");
const updatePayload = useMemo<ServerSettingsUpdate>(() => {
const update: ServerSettingsUpdate = {};
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
if (effectiveDbPurgeAge)
update.db_purge_age = Number(effectiveDbPurgeAge);
if (effectiveDbMaxMatches)
update.db_max_matches = Number(effectiveDbMaxMatches);
return update;
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(updatePayload, updateSettings);
const handleFlush = useCallback(async () => {
setFlushing(true);
setMsg(null);
try {
const result = await flush();
setMsg({ text: `Logs flushed: ${result}`, ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Flush failed.",
ok: false,
});
} finally {
setFlushing(false);
}
}, [flush]);
if (loading) {
return (
<Skeleton aria-label="Loading server settings…">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
<SkeletonItem size={32} style={{ marginBottom: 8 }} />
<SkeletonItem size={32} />
</Skeleton>
);
}
if (error)
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
return (
<div>
<div className={styles.sectionCard}>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
<div className={styles.fieldRow}>
<Field label="Log Level">
<Select
value={effectiveLogLevel}
onChange={(_e, d) => {
setLogLevel(d.value);
}}
>
{LOG_LEVELS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
</Field>
<Field label="Log Target">
<Input
value={effectiveLogTarget}
placeholder="STDOUT / /var/log/fail2ban.log"
onChange={(_e, d) => {
setLogTarget(d.value);
}}
/>
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="DB Path">
<Input
readOnly
value={settings?.db_path ?? ""}
className={styles.codeFont}
/>
</Field>
<Field label="Syslog Socket">
<Input
readOnly
value={settings?.syslog_socket ?? "(not configured)"}
className={styles.codeFont}
/>
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="DB Purge Age (s)">
<Input
type="number"
value={effectiveDbPurgeAge}
onChange={(_e, d) => {
setDbPurgeAge(d.value);
}}
/>
</Field>
<Field label="DB Max Matches">
<Input
type="number"
value={effectiveDbMaxMatches}
onChange={(_e, d) => {
setDbMaxMatches(d.value);
}}
/>
</Field>
</div>
<div className={styles.buttonRow}>
<Button
appearance="secondary"
icon={<DocumentArrowDown24Regular />}
disabled={flushing}
onClick={() => void handleFlush()}
>
{flushing ? "Flushing…" : "Flush Logs"}
</Button>
</div>
{msg && (
<MessageBar intent={msg.ok ? "success" : "error"}>
<MessageBarBody>{msg.text}</MessageBarBody>
</MessageBar>
)}
</div>
</div>
);
}