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,252 @@
/**
* JailFilesTab — manage jail.d config files with a structured form editor.
*
* Displays all jail.d config files in a collapsible accordion. Each file
* panel renders a ``JailFileForm`` with per-section auto-save editing. The
* file-level enable/disable toggle remains in the accordion header.
*
* A "Create new jail file" section at the bottom allows adding new files.
*/
import { useCallback, useEffect, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Switch,
Text,
tokens,
} from "@fluentui/react-components";
import { Add24Regular, ArrowClockwise24Regular, DocumentAdd24Regular } from "@fluentui/react-icons";
import { ApiError } from "../../api/client";
import {
createJailConfigFile,
fetchJailConfigFiles,
setJailConfigFileEnabled,
} from "../../api/config";
import type { JailConfigFile } from "../../types/config";
import { JailFileForm } from "./JailFileForm";
import { useConfigStyles } from "./configStyles";
/**
* Tab component for managing jail.d configuration files with structured forms.
*
* @returns JSX element.
*/
export function JailFilesTab(): React.JSX.Element {
const styles = useConfigStyles();
const [files, setFiles] = useState<JailConfigFile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [toggling, setToggling] = useState<string | null>(null);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
// Create new file form state
const [newFilename, setNewFilename] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const loadFiles = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const resp = await fetchJailConfigFiles();
setFiles(resp.files);
} catch (err: unknown) {
setError(
err instanceof ApiError ? err.message : "Failed to load jail config files.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
const handleToggleEnabled = useCallback(
async (filename: string, enabled: boolean): Promise<void> => {
setToggling(filename);
setMsg(null);
try {
await setJailConfigFileEnabled(filename, { enabled });
setFiles((prev) =>
prev.map((f) => (f.filename === filename ? { ...f, enabled } : f)),
);
setMsg({ text: `${filename} ${enabled ? "enabled" : "disabled"}.`, ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Toggle failed.",
ok: false,
});
} finally {
setToggling(null);
}
},
[],
);
const handleCreate = useCallback(async (): Promise<void> => {
const name = newFilename.trim();
if (!name) return;
setCreating(true);
setCreateError(null);
try {
await createJailConfigFile({ name, content: `# ${name}\n` });
setNewFilename("");
await loadFiles();
} catch (err: unknown) {
setCreateError(err instanceof ApiError ? err.message : "Failed to create file.");
} finally {
setCreating(false);
}
}, [newFilename, loadFiles]);
if (loading) {
return (
<Skeleton aria-label="Loading jail config files…">
{[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>
<Text
as="p"
size={300}
className={styles.infoText}
block
style={{ marginBottom: tokens.spacingVerticalM }}
>
Files in <code>jail.d/</code>. Toggle the switch to enable or disable a
jail config file. Changes take effect on the next fail2ban reload.
</Text>
{msg !== null && (
<MessageBar
intent={msg.ok ? "success" : "error"}
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>{msg.text}</MessageBarBody>
</MessageBar>
)}
<div className={styles.buttonRow}>
<Button
appearance="secondary"
icon={<ArrowClockwise24Regular />}
onClick={() => { void loadFiles(); }}
>
Refresh
</Button>
</div>
{files.length === 0 && (
<div className={styles.emptyState}>
<DocumentAdd24Regular
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No files found in jail.d/.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Use the form below to create your first jail config file.
</Text>
</div>
)}
<Accordion
multiple
collapsible
style={{ marginTop: tokens.spacingVerticalM }}
>
{files.map((file) => (
<AccordionItem key={file.filename} value={file.filename} className={styles.accordionItem}>
<AccordionHeader>
<span
style={{
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
}}
>
<span className={styles.codeFont}>{file.filename}</span>
<Switch
checked={file.enabled}
disabled={toggling === file.filename}
label={file.enabled ? "Enabled" : "Disabled"}
onChange={(_e, d) => {
void handleToggleEnabled(file.filename, d.checked);
}}
onClick={(e) => { e.stopPropagation(); }}
/>
</span>
</AccordionHeader>
<AccordionPanel>
<JailFileForm filename={file.filename} />
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
{/* Create new jail file */}
<div
style={{
marginTop: tokens.spacingVerticalXL,
padding: tokens.spacingVerticalM,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
}}
>
<Text size={400} weight="semibold" block style={{ marginBottom: tokens.spacingVerticalS }}>
Create New Jail File
</Text>
{createError !== null && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBarBody>{createError}</MessageBarBody>
</MessageBar>
)}
<div style={{ display: "flex", gap: tokens.spacingHorizontalM, alignItems: "flex-end" }}>
<Field label="Filename" style={{ flex: 1 }}>
<Input
value={newFilename}
placeholder="e.g. myapp.conf"
onChange={(_e, d) => { setNewFilename(d.value); }}
onKeyDown={(e) => {
if (e.key === "Enter") { void handleCreate(); }
}}
/>
</Field>
<Button
appearance="primary"
icon={<Add24Regular />}
disabled={creating || !newFilename.trim()}
onClick={() => { void handleCreate(); }}
>
{creating ? "Creating…" : "Create"}
</Button>
</div>
</div>
</div>
);
}