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:
194
frontend/src/components/config/ServerTab.tsx
Normal file
194
frontend/src/components/config/ServerTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user