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