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,328 @@
/**
* ActionForm — structured form editor for a single ``action.d/*.conf`` file.
*
* Displays parsed fields grouped into collapsible sections:
* - Includes (before / after)
* - Lifecycle commands (actionstart, actionstop, actioncheck, actionban,
* actionunban, actionflush)
* - Definition variables (extra [Definition] key-value pairs)
* - Init variables ([Init] section key-value pairs)
*
* Provides a Save button and shows saving/error state.
*/
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
Textarea,
} from "@fluentui/react-components";
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
import type { ActionConfig, ActionConfigUpdate } from "../../types/config";
import { useActionConfig } from "../../hooks/useActionConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Editable key-value table for definition_vars / init_vars. */
interface KVEditorProps {
entries: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const rows = Object.entries(entries);
const handleKeyChange = (oldKey: string, newKey: string): void => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const handleValueChange = (key: string, value: string): void => {
onChange({ ...entries, [key]: value });
};
const handleDelete = (key: string): void => {
const { [key]: _removed, ...rest } = entries;
onChange(rest);
};
const handleAdd = (): void => {
let newKey = "new_var";
let n = 1;
while (newKey in entries) {
newKey = `new_var_${String(n)}`;
n++;
}
onChange({ ...entries, [newKey]: "" });
};
return (
<div>
{rows.map(([key, value]) => (
<div key={key} className={styles.fieldRow}>
<Input
value={key}
size="small"
style={{ width: 160, fontFamily: "monospace" }}
aria-label={`Variable name: ${key}`}
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
/>
<Textarea
value={value}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
rows={value.includes("\n") ? 3 : 1}
aria-label={`Value for ${key}`}
onChange={(_e, d) => { handleValueChange(key, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(key); }}
aria-label={`Delete variable ${key}`}
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
Add variable
</Button>
</div>
);
}
/** A Textarea field for a single lifecycle command. */
interface CommandFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
}
function CommandField({ label, value, onChange }: CommandFieldProps): React.JSX.Element {
return (
<Field label={label}>
<Textarea
value={value}
onChange={(_e, d) => { onChange(d.value); }}
rows={value.split("\n").length + 1}
style={{ fontFamily: "monospace", width: "100%" }}
placeholder={`${label} command(s)`}
/>
</Field>
);
}
// ---------------------------------------------------------------------------
// ActionFormEditor
// ---------------------------------------------------------------------------
interface ActionFormEditorProps {
config: ActionConfig;
onSave: (update: ActionConfigUpdate) => Promise<void>;
}
function ActionFormEditor({
config,
onSave,
}: ActionFormEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const [before, setBefore] = useState(config.before ?? "");
const [after, setAfter] = useState(config.after ?? "");
const [actionstart, setActionstart] = useState(config.actionstart ?? "");
const [actionstop, setActionstop] = useState(config.actionstop ?? "");
const [actioncheck, setActioncheck] = useState(config.actioncheck ?? "");
const [actionban, setActionban] = useState(config.actionban ?? "");
const [actionunban, setActionunban] = useState(config.actionunban ?? "");
const [actionflush, setActionflush] = useState(config.actionflush ?? "");
const [definitionVars, setDefinitionVars] = useState<Record<string, string>>(
config.definition_vars
);
const [initVars, setInitVars] = useState<Record<string, string>>(config.init_vars);
// Reset draft when config reloads.
useEffect(() => {
setBefore(config.before ?? "");
setAfter(config.after ?? "");
setActionstart(config.actionstart ?? "");
setActionstop(config.actionstop ?? "");
setActioncheck(config.actioncheck ?? "");
setActionban(config.actionban ?? "");
setActionunban(config.actionunban ?? "");
setActionflush(config.actionflush ?? "");
setDefinitionVars(config.definition_vars);
setInitVars(config.init_vars);
}, [config]);
const autoSavePayload = useMemo<ActionConfigUpdate>(() => ({
before: before.trim() || null,
after: after.trim() || null,
actionstart: actionstart.trim() || null,
actionstop: actionstop.trim() || null,
actioncheck: actioncheck.trim() || null,
actionban: actionban.trim() || null,
actionunban: actionunban.trim() || null,
actionflush: actionflush.trim() || null,
definition_vars: definitionVars,
init_vars: initVars,
}), [
after, actionban, actioncheck, actionflush, actionstart,
actionstop, actionunban, before, definitionVars, initVars,
]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, onSave);
const defVarCount = Object.keys(definitionVars).length;
const initVarCount = Object.keys(initVars).length;
return (
<div>
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">
{config.filename}
</Text>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
<Accordion multiple collapsible defaultOpenItems={["lifecycle"]}>
{/* Includes */}
<AccordionItem value="includes" className={styles.accordionItem}>
<AccordionHeader>Includes</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="before">
<Input
value={before}
onChange={(_e, d) => { setBefore(d.value); }}
placeholder="e.g. iptables-common.conf"
className={styles.codeInput}
/>
</Field>
<Field label="after">
<Input
value={after}
onChange={(_e, d) => { setAfter(d.value); }}
className={styles.codeInput}
/>
</Field>
</div>
</AccordionPanel>
</AccordionItem>
{/* Lifecycle commands */}
<AccordionItem value="lifecycle" className={styles.accordionItem}>
<AccordionHeader>Lifecycle commands</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<CommandField label="actionstart" value={actionstart} onChange={setActionstart} />
<CommandField label="actionstop" value={actionstop} onChange={setActionstop} />
<CommandField label="actioncheck" value={actioncheck} onChange={setActioncheck} />
<CommandField label="actionban" value={actionban} onChange={setActionban} />
<CommandField label="actionunban" value={actionunban} onChange={setActionunban} />
<CommandField label="actionflush" value={actionflush} onChange={setActionflush} />
</div>
</AccordionPanel>
</AccordionItem>
{/* Definition variables */}
<AccordionItem value="definition_vars" className={styles.accordionItem}>
<AccordionHeader>
{`Definition variables (${String(defVarCount)})`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={definitionVars} onChange={setDefinitionVars} />
</div>
</AccordionPanel>
</AccordionItem>
{/* Init variables */}
<AccordionItem value="init_vars" className={styles.accordionItem}>
<AccordionHeader>
{`Init variables (${String(initVarCount)})`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={initVars} onChange={setInitVars} />
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}
// ---------------------------------------------------------------------------
// ActionForm (public export)
// ---------------------------------------------------------------------------
export interface ActionFormProps {
/** Action base name (e.g. ``"iptables"``). */
name: string;
}
/**
* Loads and renders the structured form editor for one action.
*/
export function ActionForm({ name }: ActionFormProps): React.JSX.Element {
const { config, loading, error, save } = useActionConfig(name);
if (loading) {
return (
<Skeleton aria-label={`Loading ${name}`}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
<SkeletonItem size={72} style={{ marginBottom: 8 }} />
<SkeletonItem size={72} />
</Skeleton>
);
}
if (error !== null || config === null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error ?? "Failed to load action config"}</MessageBarBody>
</MessageBar>
);
}
return (
<ActionFormEditor
config={config}
onSave={save}
/>
);
}

View File

@@ -0,0 +1,111 @@
/**
* ActionsTab — form-based accordion editor for action.d files.
*
* Shows one accordion item per action file. Expanding a panel lazily loads
* the parsed action config and renders an {@link ActionForm}.
*/
import { useEffect, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
tokens,
} from "@fluentui/react-components";
import { DocumentAdd24Regular } from "@fluentui/react-icons";
import { fetchActionFiles } from "../../api/config";
import type { ConfFileEntry } from "../../types/config";
import { ActionForm } from "./ActionForm";
import { useConfigStyles } from "./configStyles";
/**
* Tab component for the form-based action.d editor.
*
* @returns JSX element.
*/
export function ActionsTab(): React.JSX.Element {
const styles = useConfigStyles();
const [files, setFiles] = useState<ConfFileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchActionFiles()
.then((resp) => {
if (!cancelled) {
setFiles(resp.files);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load actions");
setLoading(false);
}
});
return (): void => { cancelled = true; };
}, []);
if (loading) {
return (
<Skeleton aria-label="Loading actions…">
{[0, 1, 2].map((i) => (
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
))}
</Skeleton>
);
}
if (error) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (files.length === 0) {
return (
<div className={styles.emptyState}>
<DocumentAdd24Regular
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No action files found.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Create a new action file in the Export tab.
</Text>
<Button appearance="primary" onClick={() => { window.location.hash = "#export"; }}>
Go to Export
</Button>
</div>
);
}
return (
<div className={styles.tabContent}>
<Accordion collapsible>
{files.map((f) => (
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}>
<AccordionHeader>{f.filename}</AccordionHeader>
<AccordionPanel>
<ActionForm name={f.name} />
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,124 @@
/**
* AutoSaveIndicator — small status chip showing auto-save state.
*
* Displays nothing while idle, a spinner while saving, a transient
* "Saved" checkmark on success, and an error message with retry on failure.
*
* Always renders an ``aria-live`` region so screen readers receive
* status change announcements even when the visible badge is absent.
*/
import { useEffect, useState } from "react";
import {
Badge,
Button,
Spinner,
Text,
tokens,
} from "@fluentui/react-components";
import { Checkmark16Regular } from "@fluentui/react-icons";
/** Current save state driven by the parent (typically useAutoSave). */
export type AutoSaveStatus = "idle" | "saving" | "saved" | "error";
export interface AutoSaveIndicatorProps {
/** Current save status. */
status: AutoSaveStatus;
/** Error text shown when status is "error". */
errorText?: string | null;
/** Called when the user clicks the retry link. */
onRetry?: () => void;
}
/** Fade-out delay after "saved" status in milliseconds. */
const SAVED_FADE_DELAY_MS = 2000;
/**
* Compact inline indicator for auto-save state.
*
* @param props - Component props.
* @returns JSX element.
*/
export function AutoSaveIndicator({
status,
errorText,
onRetry,
}: AutoSaveIndicatorProps): React.JSX.Element {
const [fadingOut, setFadingOut] = useState(false);
// Trigger the fade-out transition 2 s after the saved state is reached.
useEffect((): (() => void) | undefined => {
if (status !== "saved") {
setFadingOut(false);
return undefined;
}
const timer = window.setTimeout(() => {
setFadingOut(true);
}, SAVED_FADE_DELAY_MS);
return () => {
window.clearTimeout(timer);
};
}, [status]);
// Always render the aria-live region so screen readers track changes.
return (
<span
aria-live="polite"
role="status"
style={{
display: "inline-flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
minWidth: 80,
}}
>
{status === "saving" && (
<>
<Spinner size="extra-tiny" />
<Text size={200} style={{ color: tokens.colorNeutralForeground2 }}>
Saving
</Text>
</>
)}
{status === "saved" && (
<span
style={{
opacity: fadingOut ? 0 : 1,
transform: fadingOut ? "scale(0.95)" : "scale(1)",
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
animationName: fadingOut ? undefined : "fadeInScale",
animationDuration: tokens.durationFast,
animationTimingFunction: tokens.curveDecelerateMid,
animationFillMode: "both",
}}
>
<Badge
appearance="tint"
color="success"
icon={<Checkmark16Regular />}
size="small"
>
Saved
</Badge>
</span>
)}
{status === "error" && (
<>
<Text
size={200}
style={{ color: tokens.colorPaletteRedForeground3 }}
>
{errorText ?? "Save failed."}
</Text>
{onRetry && (
<Button appearance="transparent" size="small" onClick={onRetry}>
Retry
</Button>
)}
</>
)}
</span>
);
}

View File

@@ -0,0 +1,319 @@
/**
* ConfFilesTab — generic editable file list for filter.d / action.d.
*
* Renders an accordion of conf files, each with a read/edit textarea and
* individual save button. Accepts fetch/create/update callbacks so it can
* be reused for both filter and action files.
*/
import { useCallback, useEffect, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Spinner,
Text,
Textarea,
tokens,
} from "@fluentui/react-components";
import { ArrowClockwise24Regular, Save24Regular } from "@fluentui/react-icons";
import { ApiError } from "../../api/client";
import type { ConfFileEntry } from "../../types/config";
import { useConfigStyles } from "./configStyles";
export interface ConfFilesTabProps {
/** Human-readable label, e.g. "Filter" or "Action". */
label: string;
/** Fetches the list of available files. */
fetchList: () => Promise<{ files: ConfFileEntry[]; total: number }>;
/** Fetches the content of a single file by name (without extension). */
fetchFile: (name: string) => Promise<{
name: string;
filename: string;
content: string;
}>;
/** Persists updated content for the given file name. */
updateFile: (name: string, req: { content: string }) => Promise<void>;
/** Creates a new file with the given name and content. */
createFile: (req: {
name: string;
content: string;
}) => Promise<{ name: string; filename: string; content: string }>;
}
/**
* Generic tab component for editing raw conf files (filter.d or action.d).
*
* @param props - Component props.
* @returns JSX element.
*/
export function ConfFilesTab({
label,
fetchList,
fetchFile,
updateFile,
createFile,
}: ConfFilesTabProps): React.JSX.Element {
const styles = useConfigStyles();
const [files, setFiles] = useState<ConfFileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [openItems, setOpenItems] = useState<string[]>([]);
const [contents, setContents] = useState<Record<string, string>>({});
const [editedContents, setEditedContents] = useState<Record<string, string>>(
{},
);
const [saving, setSaving] = useState<string | null>(null);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [newName, setNewName] = useState("");
const [newContent, setNewContent] = useState("");
const [creating, setCreating] = useState(false);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const resp = await fetchList();
setFiles(resp.files);
} catch (err: unknown) {
setError(
err instanceof ApiError
? err.message
: `Failed to load ${label.toLowerCase()} files.`,
);
} finally {
setLoading(false);
}
}, [fetchList, label]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
const handleAccordionToggle = useCallback(
(
_e: React.SyntheticEvent,
data: { openItems: (string | number)[] },
) => {
const next = data.openItems as string[];
const newlyOpened = next.filter((v) => !openItems.includes(v));
setOpenItems(next);
for (const name of newlyOpened) {
if (!Object.prototype.hasOwnProperty.call(contents, name)) {
void fetchFile(name)
.then((c) => {
setContents((prev) => ({ ...prev, [name]: c.content }));
setEditedContents((prev) => ({ ...prev, [name]: c.content }));
})
.catch(() => {
setContents((prev) => ({
...prev,
[name]: "(failed to load)",
}));
setEditedContents((prev) => ({
...prev,
[name]: "(failed to load)",
}));
});
}
}
},
[openItems, contents, fetchFile],
);
const handleSave = useCallback(
async (name: string) => {
setSaving(name);
setMsg(null);
try {
const content = editedContents[name] ?? contents[name] ?? "";
await updateFile(name, { content });
setContents((prev) => ({ ...prev, [name]: content }));
setMsg({ text: `${name} saved.`, ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Save failed.",
ok: false,
});
} finally {
setSaving(null);
}
},
[editedContents, contents, updateFile],
);
const handleCreate = useCallback(async () => {
const name = newName.trim();
if (!name) return;
setCreating(true);
setMsg(null);
try {
const created = await createFile({ name, content: newContent });
setFiles((prev) => [
...prev,
{ name: created.name, filename: created.filename },
]);
setContents((prev) => ({ ...prev, [created.name]: created.content }));
setEditedContents((prev) => ({
...prev,
[created.name]: created.content,
}));
setNewName("");
setNewContent("");
setMsg({ text: `${created.filename} created.`, ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Create failed.",
ok: false,
});
} finally {
setCreating(false);
}
}, [newName, newContent, createFile]);
if (loading) return <Spinner label={`Loading ${label.toLowerCase()} files…`} />;
if (error)
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
return (
<div>
{msg && (
<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 && (
<Text
className={styles.infoText}
style={{ marginTop: tokens.spacingVerticalM }}
>
No {label.toLowerCase()} files found.
</Text>
)}
<Accordion
multiple
collapsible
openItems={openItems}
onToggle={handleAccordionToggle}
style={{ marginTop: tokens.spacingVerticalM }}
>
{files.map((file) => (
<AccordionItem key={file.name} value={file.name}>
<AccordionHeader>
<Text className={styles.codeFont}>{file.filename}</Text>
</AccordionHeader>
<AccordionPanel>
{openItems.includes(file.name) &&
(contents[file.name] === undefined ? (
<Spinner size="tiny" label="Loading…" />
) : (
<div>
<Textarea
value={editedContents[file.name] ?? ""}
rows={20}
style={{
width: "100%",
resize: "vertical",
fontFamily: "monospace",
}}
onChange={(_e, d) => {
setEditedContents((prev) => ({
...prev,
[file.name]: d.value,
}));
}}
/>
<div className={styles.buttonRow}>
<Button
appearance="primary"
icon={<Save24Regular />}
disabled={saving === file.name}
onClick={() => void handleSave(file.name)}
>
{saving === file.name ? "Saving…" : "Save"}
</Button>
</div>
</div>
))}
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
{/* Create new file */}
<div
style={{
marginTop: tokens.spacingVerticalXL,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
paddingTop: tokens.spacingVerticalM,
}}
>
<Text
as="h3"
size={400}
weight="semibold"
block
style={{ marginBottom: tokens.spacingVerticalS }}
>
New {label} File
</Text>
<Field label="Name (without .conf extension)">
<Input
value={newName}
placeholder={`e.g. my-${label.toLowerCase()}`}
className={styles.codeFont}
onChange={(_e, d) => {
setNewName(d.value);
}}
/>
</Field>
<Field label="Content" style={{ marginTop: tokens.spacingVerticalS }}>
<Textarea
value={newContent}
rows={10}
placeholder={`[Definition]\n# …`}
style={{
width: "100%",
resize: "vertical",
fontFamily: "monospace",
}}
onChange={(_e, d) => {
setNewContent(d.value);
}}
/>
</Field>
<div className={styles.buttonRow}>
<Button
appearance="primary"
disabled={creating || !newName.trim()}
onClick={() => void handleCreate()}
>
{creating ? "Creating…" : `Create ${label} File`}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
/**
* ExportTab — raw config file editing escape hatch.
*
* Three sub-tabs: "Jail Files", "Filters", "Actions". Each sub-tab gives
* direct access to the raw file content via textarea + per-file Save button,
* identical to the behaviour that was previously in the dedicated tabs.
*
* This tab provides an advanced interface for users who need to edit config
* files directly without the structured form UI.
*/
import { useState } from "react";
import { Tab, TabList, Text, tokens } from "@fluentui/react-components";
import {
fetchActionFile,
fetchActionFiles,
fetchFilterFile,
fetchFilterFiles,
createActionFile,
createFilterFile,
updateActionFile,
updateFilterFile,
} from "../../api/config";
import { JailFilesTab } from "./JailFilesTab";
import { ConfFilesTab } from "./ConfFilesTab";
type ExportSubTab = "jailfiles" | "filters" | "actions";
/**
* Export tab containing raw-file editors for jail, filter, and action files.
*
* @returns JSX element.
*/
export function ExportTab(): React.JSX.Element {
const [sub, setSub] = useState<ExportSubTab>("jailfiles");
return (
<div>
<Text
as="p"
size={300}
style={{
color: tokens.colorNeutralForeground3,
fontStyle: "italic",
marginBottom: tokens.spacingVerticalM,
}}
>
Direct raw-file editing for advanced users. Changes take effect on
next fail2ban reload.
</Text>
<TabList
appearance="subtle"
selectedValue={sub}
onTabSelect={(_e, d) => {
setSub(d.value as ExportSubTab);
}}
style={{ marginBottom: tokens.spacingVerticalM }}
>
<Tab value="jailfiles">Jail Files</Tab>
<Tab value="filters">Filters</Tab>
<Tab value="actions">Actions</Tab>
</TabList>
{sub === "jailfiles" && <JailFilesTab />}
{sub === "filters" && (
<ConfFilesTab
label="Filter"
fetchList={fetchFilterFiles}
fetchFile={fetchFilterFile}
updateFile={updateFilterFile}
createFile={createFilterFile}
/>
)}
{sub === "actions" && (
<ConfFilesTab
label="Action"
fetchList={fetchActionFiles}
fetchFile={fetchActionFile}
updateFile={updateActionFile}
createFile={createActionFile}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,332 @@
/**
* FilterForm — structured form editor for a single ``filter.d/*.conf`` file.
*
* Displays parsed fields grouped into collapsible sections:
* - Includes (before / after)
* - Variables ([DEFAULT] key-value pairs)
* - Definition (prefregex, failregex, ignoreregex, optional advanced fields)
*
* Provides a Save button and shows saving/error state.
*/
import { useEffect, useMemo, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
Textarea,
} from "@fluentui/react-components";
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
import type { FilterConfig, FilterConfigUpdate } from "../../types/config";
import { useFilterConfig } from "../../hooks/useFilterConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { RegexList } from "./RegexList";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Simple editable key-value table for [DEFAULT] variables. */
interface KVEditorProps {
entries: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const rows = Object.entries(entries);
const handleKeyChange = (oldKey: string, newKey: string): void => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const handleValueChange = (key: string, value: string): void => {
onChange({ ...entries, [key]: value });
};
const handleDelete = (key: string): void => {
const { [key]: _removed, ...rest } = entries;
onChange(rest);
};
const handleAdd = (): void => {
let newKey = "new_var";
let n = 1;
while (newKey in entries) {
newKey = `new_var_${String(n)}`;
n++;
}
onChange({ ...entries, [newKey]: "" });
};
return (
<div>
{rows.map(([key, value]) => (
<div key={key} className={styles.fieldRow}>
<Input
value={key}
size="small"
style={{ width: 160, fontFamily: "monospace" }}
aria-label={`Variable name: ${key}`}
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
/>
<Input
value={value}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
aria-label={`Value for ${key}`}
onChange={(_e, d) => { handleValueChange(key, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(key); }}
aria-label={`Delete variable ${key}`}
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
Add variable
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// FilterFormEditor — rendered once config is loaded
// ---------------------------------------------------------------------------
interface FilterFormEditorProps {
config: FilterConfig;
onSave: (update: FilterConfigUpdate) => Promise<void>;
}
function FilterFormEditor({
config,
onSave,
}: FilterFormEditorProps): React.JSX.Element {
const styles = useConfigStyles();
// Local draft state — initialised from the loaded config.
const [before, setBefore] = useState(config.before ?? "");
const [after, setAfter] = useState(config.after ?? "");
const [variables, setVariables] = useState<Record<string, string>>(config.variables);
const [prefregex, setPrefregex] = useState(config.prefregex ?? "");
const [failregex, setFailregex] = useState<string[]>(config.failregex);
const [ignoreregex, setIgnoreregex] = useState<string[]>(config.ignoreregex);
const [maxlines, setMaxlines] = useState(
config.maxlines !== null ? String(config.maxlines) : ""
);
const [datepattern, setDatepattern] = useState(config.datepattern ?? "");
const [journalmatch, setJournalmatch] = useState(config.journalmatch ?? "");
// Reset draft whenever a freshly-loaded config arrives.
useEffect(() => {
setBefore(config.before ?? "");
setAfter(config.after ?? "");
setVariables(config.variables);
setPrefregex(config.prefregex ?? "");
setFailregex(config.failregex);
setIgnoreregex(config.ignoreregex);
setMaxlines(config.maxlines !== null ? String(config.maxlines) : "");
setDatepattern(config.datepattern ?? "");
setJournalmatch(config.journalmatch ?? "");
}, [config]);
const autoSavePayload = useMemo<FilterConfigUpdate>(() => {
const parsedMax = maxlines.trim() !== "" ? parseInt(maxlines, 10) : null;
return {
before: before.trim() || null,
after: after.trim() || null,
variables,
prefregex: prefregex.trim() || null,
failregex,
ignoreregex,
maxlines: parsedMax !== null && !isNaN(parsedMax) ? parsedMax : null,
datepattern: datepattern.trim() || null,
journalmatch: journalmatch.trim() || null,
};
}, [after, before, datepattern, failregex, ignoreregex, journalmatch, maxlines, prefregex, variables]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, onSave);
const hasIncludes = config.before !== null || config.after !== null;
const varCount = Object.keys(variables).length;
return (
<div>
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">
{config.filename}
</Text>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
<Accordion multiple collapsible defaultOpenItems={["definition"]}>
{/* Includes */}
<AccordionItem value="includes" className={styles.accordionItem}>
<AccordionHeader>
{`Includes${hasIncludes ? "" : " (none)"}`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="before">
<Input
value={before}
onChange={(_e, d) => { setBefore(d.value); }}
placeholder="e.g. common.conf"
className={styles.codeInput}
/>
</Field>
<Field label="after">
<Input
value={after}
onChange={(_e, d) => { setAfter(d.value); }}
placeholder="e.g. sshd-aggressive.conf"
className={styles.codeInput}
/>
</Field>
</div>
</AccordionPanel>
</AccordionItem>
{/* Variables */}
<AccordionItem value="variables" className={styles.accordionItem}>
<AccordionHeader>
{`Variables (${String(varCount)})`}
</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor entries={variables} onChange={setVariables} />
</div>
</AccordionPanel>
</AccordionItem>
{/* Definition */}
<AccordionItem value="definition" className={styles.accordionItem}>
<AccordionHeader>Definition</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<Field label="prefregex">
<Textarea
value={prefregex}
onChange={(_e, d) => { setPrefregex(d.value); }}
placeholder="Prefix regex prepended to all failregex patterns"
className={styles.codeInput}
rows={2}
/>
</Field>
<RegexList label="failregex" patterns={failregex} onChange={setFailregex} />
<RegexList label="ignoreregex" patterns={ignoreregex} onChange={setIgnoreregex} />
{/* Advanced optional fields */}
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: "pointer", userSelect: "none", marginBottom: 8 }}>
Advanced options
</summary>
<Field label="maxlines">
<Input
type="number"
value={maxlines}
onChange={(_e, d) => { setMaxlines(d.value); }}
placeholder="e.g. 10"
style={{ width: 100 }}
/>
</Field>
<Field label="datepattern">
<Input
value={datepattern}
onChange={(_e, d) => { setDatepattern(d.value); }}
placeholder="e.g. %Y-%m-%d %H:%M:%S"
className={styles.codeInput}
/>
</Field>
<Field label="journalmatch">
<Input
value={journalmatch}
onChange={(_e, d) => { setJournalmatch(d.value); }}
placeholder="e.g. _SYSTEMD_UNIT=sshd.service"
className={styles.codeInput}
/>
</Field>
</details>
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
);
}
// ---------------------------------------------------------------------------
// FilterForm (public export)
// ---------------------------------------------------------------------------
export interface FilterFormProps {
/** Filter base name (e.g. ``"sshd"``). */
name: string;
}
/**
* Loads and renders the structured form editor for one filter.
* Shows a spinner while loading and an error message on failure.
*/
export function FilterForm({ name }: FilterFormProps): React.JSX.Element {
const { config, loading, error, save } = useFilterConfig(name);
if (loading) {
return (
<Skeleton aria-label={`Loading ${name}`}>
<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={72} />
</Skeleton>
);
}
if (error !== null || config === null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error ?? "Failed to load filter config"}</MessageBarBody>
</MessageBar>
);
}
return (
<FilterFormEditor
config={config}
onSave={save}
/>
);
}

View File

@@ -0,0 +1,111 @@
/**
* FiltersTab — form-based accordion editor for filter.d files.
*
* Shows one accordion item per filter file. Expanding a panel lazily loads
* the parsed filter config and renders a {@link FilterForm}.
*/
import { useEffect, useState } from "react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
tokens,
} from "@fluentui/react-components";
import { DocumentAdd24Regular } from "@fluentui/react-icons";
import { fetchFilterFiles } from "../../api/config";
import type { ConfFileEntry } from "../../types/config";
import { FilterForm } from "./FilterForm";
import { useConfigStyles } from "./configStyles";
/**
* Tab component for the form-based filter.d editor.
*
* @returns JSX element.
*/
export function FiltersTab(): React.JSX.Element {
const styles = useConfigStyles();
const [files, setFiles] = useState<ConfFileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchFilterFiles()
.then((resp) => {
if (!cancelled) {
setFiles(resp.files);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load filters");
setLoading(false);
}
});
return (): void => { cancelled = true; };
}, []);
if (loading) {
return (
<Skeleton aria-label="Loading filters…">
{[0, 1, 2].map((i) => (
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
))}
</Skeleton>
);
}
if (error) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (files.length === 0) {
return (
<div className={styles.emptyState}>
<DocumentAdd24Regular
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No filter files found.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Create a new filter file in the Export tab.
</Text>
<Button appearance="primary" onClick={() => { window.location.hash = "#export"; }}>
Go to Export
</Button>
</div>
);
}
return (
<div className={styles.tabContent}>
<Accordion collapsible>
{files.map((f) => (
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}>
<AccordionHeader>{f.filename}</AccordionHeader>
<AccordionPanel>
<FilterForm name={f.name} />
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,142 @@
/**
* GlobalTab — global fail2ban settings editor.
*
* Provides form fields for log level, log target, database purge age,
* and database max matches.
*/
import { useEffect, useMemo, useState } from "react";
import {
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
} from "@fluentui/react-components";
import type { GlobalConfigUpdate } from "../../types/config";
import { useGlobalConfig } 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 global fail2ban configuration.
*
* @returns JSX element.
*/
export function GlobalTab(): React.JSX.Element {
const styles = useConfigStyles();
const { config, loading, error, updateConfig } = useGlobalConfig();
const [logLevel, setLogLevel] = useState("");
const [logTarget, setLogTarget] = useState("");
const [dbPurgeAge, setDbPurgeAge] = useState("");
const [dbMaxMatches, setDbMaxMatches] = useState("");
// Sync local state when config loads for the first time.
useEffect(() => {
if (config && logLevel === "") {
setLogLevel(config.log_level);
setLogTarget(config.log_target);
setDbPurgeAge(String(config.db_purge_age));
setDbMaxMatches(String(config.db_max_matches));
}
// Only run on first config load.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const effectiveLogLevel = logLevel || config?.log_level || "";
const effectiveLogTarget = logTarget || config?.log_target || "";
const effectiveDbPurgeAge =
dbPurgeAge || (config ? String(config.db_purge_age) : "");
const effectiveDbMaxMatches =
dbMaxMatches || (config ? String(config.db_max_matches) : "");
const updatePayload = useMemo<GlobalConfigUpdate>(() => {
const update: GlobalConfigUpdate = {};
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, updateConfig);
if (loading) return <Spinner label="Loading global config…" />;
if (error)
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
return (
<div>
<div className={styles.sectionCard}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
<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 Purge Age (s)"
hint="Ban records older than this are removed from the fail2ban database."
>
<Input
type="number"
value={effectiveDbPurgeAge}
onChange={(_e, d) => {
setDbPurgeAge(d.value);
}}
/>
</Field>
<Field
label="DB Max Matches"
hint="Maximum number of log-line matches stored per ban record."
>
<Input
type="number"
value={effectiveDbMaxMatches}
onChange={(_e, d) => {
setDbMaxMatches(d.value);
}}
/>
</Field>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,474 @@
/**
* JailFileForm — structured form editor for a single ``jail.d/*.conf`` file.
*
* Renders each jail section in the file as an accordion panel with fields for
* all common jail settings plus log paths, action references, and extra keys.
*
* All fields auto-save through ``useAutoSave``.
*/
import { useCallback, useEffect, 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 { Add24Regular, Delete24Regular, DocumentAdd24Regular } from "@fluentui/react-icons";
import type { JailFileConfig, JailFileConfigUpdate, JailSectionConfig } from "../../types/config";
import { useJailFileConfig } from "../../hooks/useJailFileConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// StringListEditor — add / remove lines in a list field
// ---------------------------------------------------------------------------
interface StringListEditorProps {
items: string[];
onChange: (next: string[]) => void;
placeholder?: string;
addLabel?: string;
}
function StringListEditor({
items,
onChange,
placeholder,
addLabel = "Add entry",
}: StringListEditorProps): React.JSX.Element {
const handleChange = (index: number, value: string): void => {
const next = [...items];
next[index] = value;
onChange(next);
};
const handleDelete = (index: number): void => {
onChange(items.filter((_, i) => i !== index));
};
const handleAdd = (): void => {
onChange([...items, ""]);
};
return (
<div>
{items.map((item, index) => (
<div key={index} style={{ display: "flex", gap: 4, marginBottom: 4, alignItems: "center" }}>
<Input
value={item}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
placeholder={placeholder}
aria-label={`Entry ${String(index + 1)}${item ? `: ${item}` : ""}`}
onChange={(_e, d) => { handleChange(index, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(index); }}
aria-label="Remove entry"
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
{addLabel}
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// KVEditor — key-value pair list (for extra settings)
// ---------------------------------------------------------------------------
interface KVEditorProps {
entries: Record<string, string>;
onChange: (next: Record<string, string>) => void;
}
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
const styles = useConfigStyles();
const rows = Object.entries(entries);
const handleKeyChange = (oldKey: string, newKey: string): void => {
const next: Record<string, string> = {};
for (const [k, v] of Object.entries(entries)) {
next[k === oldKey ? newKey : k] = v;
}
onChange(next);
};
const handleValueChange = (key: string, value: string): void => {
onChange({ ...entries, [key]: value });
};
const handleDelete = (key: string): void => {
const { [key]: _removed, ...rest } = entries;
onChange(rest);
};
const handleAdd = (): void => {
let newKey = "key";
let n = 1;
while (newKey in entries) {
newKey = `key${String(n)}`;
n++;
}
onChange({ ...entries, [newKey]: "" });
};
return (
<div>
{rows.map(([key, value]) => (
<div key={key} className={styles.fieldRow}>
<Input
value={key}
size="small"
style={{ width: 140, fontFamily: "monospace" }}
aria-label={`Setting name: ${key}`}
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
/>
<Input
value={value}
size="small"
style={{ flex: 1, fontFamily: "monospace" }}
aria-label={`Value for ${key}`}
onChange={(_e, d) => { handleValueChange(key, d.value); }}
/>
<Button
icon={<Delete24Regular />}
size="small"
appearance="subtle"
onClick={() => { handleDelete(key); }}
aria-label={`Delete key ${key}`}
/>
</div>
))}
<Button
icon={<Add24Regular />}
size="small"
appearance="outline"
onClick={handleAdd}
style={{ marginTop: 4 }}
>
Add setting
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// JailSectionPanel — form fields for one [jailname] section
// ---------------------------------------------------------------------------
const BACKENDS = ["", "auto", "polling", "gamin", "pyinotify", "systemd"] as const;
interface JailSectionPanelProps {
jailName: string;
section: JailSectionConfig;
onChange: (next: JailSectionConfig) => void;
}
function JailSectionPanel({
jailName,
section,
onChange,
}: JailSectionPanelProps): React.JSX.Element {
const styles = useConfigStyles();
const update = useCallback(
(patch: Partial<JailSectionConfig>): void => {
onChange({ ...section, ...patch });
},
[onChange, section]
);
return (
<div>
{/* Core fields grid */}
<div className={styles.sectionCard}>
<div className={styles.fieldRow}>
<Field label="Enabled">
<Switch
checked={section.enabled ?? false}
onChange={(_e, d) => { update({ enabled: d.checked }); }}
aria-label={`Enable jail ${jailName}`}
/>
</Field>
<Field label="Port">
<Input
value={section.port ?? ""}
size="small"
placeholder="e.g. ssh or 22"
onChange={(_e, d) => { update({ port: d.value || null }); }}
/>
</Field>
<Field label="Filter">
<Input
value={section.filter ?? ""}
size="small"
placeholder="e.g. sshd"
className={styles.codeInput}
onChange={(_e, d) => { update({ filter: d.value || null }); }}
/>
</Field>
<Field label="Backend">
<Select
size="small"
value={section.backend ?? ""}
onChange={(_e, d) => { update({ backend: d.value || null }); }}
>
{BACKENDS.map((b) => (
<option key={b} value={b}>{b === "" ? "(default)" : b}</option>
))}
</Select>
</Field>
<Field label="Max Retry">
<Input
value={section.maxretry !== null ? String(section.maxretry) : ""}
size="small"
type="number"
placeholder="e.g. 5"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ maxretry: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
<Field label="Find Time (s)">
<Input
value={section.findtime !== null ? String(section.findtime) : ""}
size="small"
type="number"
placeholder="e.g. 600"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ findtime: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
<Field label="Ban Time (s)">
<Input
value={section.bantime !== null ? String(section.bantime) : ""}
size="small"
type="number"
placeholder="e.g. 3600"
onChange={(_e, d) => {
const n = parseInt(d.value, 10);
update({ bantime: d.value === "" ? null : isNaN(n) ? null : n });
}}
/>
</Field>
</div>
</div>
{/* Log paths */}
<Accordion multiple collapsible defaultOpenItems={["logpath"]}>
<AccordionItem value="logpath" className={styles.accordionItem}>
<AccordionHeader>Log Paths ({section.logpath.length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<StringListEditor
items={section.logpath}
onChange={(next) => { update({ logpath: next }); }}
placeholder="e.g. /var/log/auth.log"
addLabel="Add log path"
/>
</div>
</AccordionPanel>
</AccordionItem>
{/* Actions */}
<AccordionItem value="actions" className={styles.accordionItem}>
<AccordionHeader>Actions ({section.action.length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<StringListEditor
items={section.action}
onChange={(next) => { update({ action: next }); }}
placeholder="e.g. iptables-multiport[name=SSH, port=ssh]"
addLabel="Add action"
/>
</div>
</AccordionPanel>
</AccordionItem>
{/* Extra settings */}
{(Object.keys(section.extra).length > 0) && (
<AccordionItem value="extra" className={styles.accordionItem}>
<AccordionHeader>Extra Settings ({Object.keys(section.extra).length})</AccordionHeader>
<AccordionPanel>
<div className={styles.sectionCard}>
<KVEditor
entries={section.extra}
onChange={(next) => { update({ extra: next }); }}
/>
</div>
</AccordionPanel>
</AccordionItem>
)}
</Accordion>
</div>
);
}
// ---------------------------------------------------------------------------
// JailFileFormInner — rendered once config is loaded
// ---------------------------------------------------------------------------
interface JailFileFormInnerProps {
config: JailFileConfig;
onSave: (update: JailFileConfigUpdate) => Promise<void>;
}
function JailFileFormInner({
config,
onSave,
}: JailFileFormInnerProps): React.JSX.Element {
const [jails, setJails] = useState<Record<string, JailSectionConfig>>(config.jails);
// Reset when a freshly-loaded config arrives.
useEffect(() => {
setJails(config.jails);
}, [config]);
const autoSavePayload = useMemo<JailFileConfigUpdate>(
() => ({ jails }),
[jails]
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, onSave);
const handleSectionChange = useCallback(
(jailName: string, next: JailSectionConfig): void => {
setJails((prev) => ({ ...prev, [jailName]: next }));
},
[]
);
const styles = useConfigStyles();
const jailNames = Object.keys(jails);
return (
<div>
{/* Header row */}
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
<Text size={500} weight="semibold">{config.filename}</Text>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
{jailNames.length === 0 ? (
<div className={styles.emptyState}>
<DocumentAdd24Regular
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No jail sections found in this file.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Add a <code>[jailname]</code> section to the file to define jails.
</Text>
</div>
) : (
<Accordion multiple collapsible defaultOpenItems={jailNames}>
{jailNames.map((jailName) => {
const section = jails[jailName];
if (section === undefined) return null;
return (
<AccordionItem key={jailName} value={jailName} className={styles.accordionItem}>
<AccordionHeader>
<Badge appearance="filled" color="informative" style={{ marginRight: 6 }}>
{jailName}
</Badge>
{section.enabled === true
? "(enabled)"
: section.enabled === false
? "(disabled)"
: ""}
</AccordionHeader>
<AccordionPanel>
<JailSectionPanel
jailName={jailName}
section={section}
onChange={(next) => { handleSectionChange(jailName, next); }}
/>
</AccordionPanel>
</AccordionItem>
);
})}
</Accordion>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// JailFileForm — public component
// ---------------------------------------------------------------------------
interface JailFileFormProps {
filename: string;
}
/**
* Load and render a structured form editor for a ``jail.d/*.conf`` file.
*
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/
export function JailFileForm({ filename }: JailFileFormProps): React.JSX.Element {
const { config, loading, error, save } = useJailFileConfig(filename);
if (loading) {
return (
<Skeleton aria-label="Loading jail file config…">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
<SkeletonItem size={32} />
</Skeleton>
);
}
if (error !== null) {
return (
<MessageBar intent="error" style={{ margin: "8px 0" }}>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (config === null) {
return <></>;
}
return <JailFileFormInner config={config} onSave={save} />;
}

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>
);
}

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>
);
}

View File

@@ -0,0 +1,212 @@
/**
* MapTab — world map color threshold configuration editor.
*
* Allows the user to set the low / medium / high ban-count thresholds
* that drive country fill colors on the World Map page.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
tokens,
} from "@fluentui/react-components";
import { ApiError } from "../../api/client";
import {
fetchMapColorThresholds,
updateMapColorThresholds,
} from "../../api/config";
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Inner form — only mounted after data is loaded.
// ---------------------------------------------------------------------------
interface MapFormProps {
initial: MapColorThresholdsResponse;
}
function MapForm({ initial }: MapFormProps): React.JSX.Element {
const styles = useConfigStyles();
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
const high = Number(thresholdHigh);
const medium = Number(thresholdMedium);
const low = Number(thresholdLow);
const validationError = useMemo<string | null>(() => {
if (isNaN(high) || isNaN(medium) || isNaN(low))
return "All thresholds must be valid numbers.";
if (high <= 0 || medium <= 0 || low <= 0)
return "All thresholds must be positive integers.";
if (!(high > medium && medium > low))
return "Thresholds must satisfy: high > medium > low.";
return null;
}, [high, medium, low]);
// Only pass a new payload to useAutoSave when all values are valid.
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
threshold_high: initial.threshold_high,
threshold_medium: initial.threshold_medium,
threshold_low: initial.threshold_low,
});
useEffect(() => {
if (validationError !== null) return;
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
}, [high, medium, low, validationError]);
const saveThresholds = useCallback(
async (payload: MapColorThresholdsUpdate): Promise<void> => {
await updateMapColorThresholds(payload);
},
[],
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(validPayload, saveThresholds);
return (
<div>
<div className={styles.sectionCard}>
<Text as="h3" size={500} weight="semibold" block>
Map Color Thresholds
</Text>
<Text
as="p"
size={300}
className={styles.infoText}
block
style={{ marginBottom: tokens.spacingVerticalM }}
>
Configure the ban count thresholds that determine country fill colors on
the World Map. Countries with zero bans remain transparent. Colors
smoothly interpolate between thresholds.
</Text>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={validationError ? "idle" : saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
{validationError && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBarBody>{validationError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.fieldRowThree}>
<Field label="Low Threshold (Green)" required>
<Input
type="number"
value={thresholdLow}
onChange={(_, d) => {
setThresholdLow(d.value);
}}
min={1}
/>
</Field>
<Field label="Medium Threshold (Yellow)" required>
<Input
type="number"
value={thresholdMedium}
onChange={(_, d) => {
setThresholdMedium(d.value);
}}
min={1}
/>
</Field>
<Field label="High Threshold (Red)" required>
<Input
type="number"
value={thresholdHigh}
onChange={(_, d) => {
setThresholdHigh(d.value);
}}
min={1}
/>
</Field>
</div>
<Text
as="p"
size={200}
className={styles.infoText}
style={{ marginTop: tokens.spacingVerticalS }}
>
1 to {thresholdLow}: Light green Full green
<br /> {thresholdLow} to {thresholdMedium}: Green Yellow
<br /> {thresholdMedium} to {thresholdHigh}: Yellow Red
<br /> {thresholdHigh}+: Solid red
</Text>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Outer loader component.
// ---------------------------------------------------------------------------
/**
* Tab component for editing world-map ban-count color thresholds.
*
* @returns JSX element.
*/
export function MapTab(): React.JSX.Element {
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
try {
const data = await fetchMapColorThresholds();
setThresholds(data);
} catch (err) {
setLoadError(
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
);
}
}, []);
useEffect(() => {
void load();
}, [load]);
if (!thresholds && !loadError) {
return (
<Skeleton aria-label="Loading map settings…">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
</Skeleton>
);
}
if (loadError)
return (
<MessageBar intent="error">
<MessageBarBody>{loadError}</MessageBarBody>
</MessageBar>
);
if (!thresholds) return <></>;
return <MapForm initial={thresholds} />;
}

View File

@@ -0,0 +1,103 @@
/**
* RegexList — editable list of regex patterns.
*
* Renders a list of monospace inputs with add/delete controls.
* Used in jail config panels and the filter form.
*/
import { useCallback, useState } from "react";
import { Button, Input, Text } from "@fluentui/react-components";
import { Dismiss24Regular } from "@fluentui/react-icons";
import { useConfigStyles } from "./configStyles";
export interface RegexListProps {
/** Section label displayed above the list. */
label: string;
/** Current list of regex patterns. */
patterns: string[];
/** Called when the list changes (add, delete, or edit). */
onChange: (next: string[]) => void;
}
/**
* Renders an editable list of regex patterns with add and delete controls.
*
* @param props - Component props.
* @returns JSX element.
*/
export function RegexList({
label,
patterns,
onChange,
}: RegexListProps): React.JSX.Element {
const styles = useConfigStyles();
const [newPattern, setNewPattern] = useState("");
const handleAdd = useCallback(() => {
const p = newPattern.trim();
if (p) {
onChange([...patterns, p]);
setNewPattern("");
}
}, [newPattern, patterns, onChange]);
const handleDelete = useCallback(
(idx: number) => {
onChange(patterns.filter((_, i) => i !== idx));
},
[patterns, onChange],
);
return (
<div>
<Text size={200} weight="semibold">
{label}
</Text>
{patterns.length === 0 && (
<Text className={styles.infoText} size={200}>
{" "}
(none)
</Text>
)}
{patterns.map((p, i) => (
<div key={i} className={styles.regexItem}>
<Input
className={styles.regexInput}
value={p}
aria-label={`${label} pattern ${String(i + 1)}`}
onChange={(_e, d) => {
const next = [...patterns];
next[i] = d.value;
onChange(next);
}}
/>
<Button
appearance="subtle"
icon={<Dismiss24Regular />}
size="small"
aria-label={`Remove ${label} pattern ${String(i + 1)}`}
onClick={() => {
handleDelete(i);
}}
/>
</div>
))}
<div className={styles.regexItem}>
<Input
className={styles.regexInput}
placeholder="New pattern…"
value={newPattern}
onChange={(_e, d) => {
setNewPattern(d.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd();
}}
/>
<Button size="small" onClick={handleAdd}>
Add
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
/**
* RegexTesterTab — live regex pattern tester and log file preview.
*
* Provides two panels:
* 1. Single-line tester: paste a log line and a regex, get a match result.
* 2. Log file preview: read N lines from a server file and highlight matches.
*/
import { useCallback, useState } from "react";
import {
Badge,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Text,
Textarea,
tokens,
} from "@fluentui/react-components";
import {
Checkmark24Regular,
Dismiss24Regular,
} from "@fluentui/react-icons";
import { useLogPreview, useRegexTester } from "../../hooks/useConfig";
import { useConfigStyles } from "./configStyles";
/**
* Tab component for testing regex patterns against log lines or full files.
*
* @returns JSX element.
*/
export function RegexTesterTab(): React.JSX.Element {
const styles = useConfigStyles();
const { result, testing, test } = useRegexTester();
const { preview, loading: previewing, run: runPreview } = useLogPreview();
const [logLine, setLogLine] = useState("");
const [pattern, setPattern] = useState("");
const [previewPath, setPreviewPath] = useState("");
const [previewLines, setPreviewLines] = useState("200");
const handleTest = useCallback(async () => {
if (!logLine.trim() || !pattern.trim()) return;
await test({ log_line: logLine, fail_regex: pattern });
}, [logLine, pattern, test]);
const handlePreview = useCallback(async () => {
if (!previewPath.trim() || !pattern.trim()) return;
await runPreview({
log_path: previewPath,
fail_regex: pattern,
num_lines: Number(previewLines) || 200,
});
}, [previewPath, pattern, previewLines, runPreview]);
return (
<div>
{/* Single-line tester */}
<Text as="h3" size={500} weight="semibold" block>
Regex Tester
</Text>
<Text size={200} className={styles.infoText} block>
Test a pattern against a single sample log line.
</Text>
<div style={{ marginTop: tokens.spacingVerticalS }}>
<Field label="Fail Regex Pattern">
<Input
className={styles.codeFont}
value={pattern}
placeholder="e.g. (?P<host>\S+)"
onChange={(_e, d) => {
setPattern(d.value);
}}
/>
</Field>
<Field
label="Sample Log Line"
style={{ marginTop: tokens.spacingVerticalS }}
>
<Textarea
className={styles.codeFont}
value={logLine}
placeholder="Paste a log line here…"
rows={3}
onChange={(_e, d) => {
setLogLine(d.value);
}}
/>
</Field>
</div>
<div className={styles.buttonRow}>
<Button
appearance="primary"
disabled={testing || !logLine.trim() || !pattern.trim()}
onClick={() => void handleTest()}
>
{testing ? "Testing…" : "Test Pattern"}
</Button>
</div>
{result && (
<div
style={{
marginTop: tokens.spacingVerticalM,
padding: tokens.spacingVerticalS,
border: `1px solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
}}
>
{result.error ? (
<MessageBar intent="error">
<MessageBarBody>Regex error: {result.error}</MessageBarBody>
</MessageBar>
) : (
<>
<Badge
size="large"
appearance="filled"
color={result.matched ? "success" : "danger"}
icon={
result.matched ? (
<Checkmark24Regular />
) : (
<Dismiss24Regular />
)
}
>
{result.matched ? "Matched" : "No match"}
</Badge>
{result.matched && result.groups.length > 0 && (
<div style={{ marginTop: tokens.spacingVerticalS }}>
<Text size={200} weight="semibold">
Captured groups:
</Text>
{result.groups.map((g, i) => (
<Badge
key={i}
appearance="tint"
color="informative"
style={{ marginLeft: tokens.spacingHorizontalXS }}
className={styles.codeFont}
>
{g}
</Badge>
))}
</div>
)}
</>
)}
</div>
)}
{/* Log file preview */}
<div style={{ marginTop: tokens.spacingVerticalXL }}>
<Text as="h3" size={500} weight="semibold" block>
Log File Preview
</Text>
<Text size={200} className={styles.infoText} block>
Read the last N lines from a log file on the server and highlight
matches.
</Text>
<div
className={styles.fieldRow}
style={{ marginTop: tokens.spacingVerticalS }}
>
<Field label="Log File Path">
<Input
className={styles.codeFont}
value={previewPath}
placeholder="/var/log/auth.log"
onChange={(_e, d) => {
setPreviewPath(d.value);
}}
/>
</Field>
<Field label="Lines to Read">
<Input
type="number"
value={previewLines}
onChange={(_e, d) => {
setPreviewLines(d.value);
}}
/>
</Field>
</div>
<div className={styles.buttonRow}>
<Button
appearance="secondary"
disabled={previewing || !previewPath.trim() || !pattern.trim()}
onClick={() => void handlePreview()}
>
{previewing ? "Loading…" : "Preview Log"}
</Button>
</div>
{preview && (
<div style={{ marginTop: tokens.spacingVerticalS }}>
{preview.regex_error ? (
<MessageBar intent="error">
<MessageBarBody>{preview.regex_error}</MessageBarBody>
</MessageBar>
) : (
<>
<Text size={200}>
{preview.matched_count} / {preview.total_lines} lines matched
</Text>
<div className={styles.previewArea}>
{preview.lines.map((ln, idx) => (
<div
key={idx}
className={`${styles.logLine} ${ln.matched ? styles.matched : styles.notMatched}`}
>
{ln.line}
</div>
))}
</div>
</>
)}
</div>
)}
</div>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ActionForm } from "../ActionForm";
import type { ActionConfig } from "../../../types/config";
// Mock the useActionConfig hook so tests don't make real API calls.
vi.mock("../../../hooks/useActionConfig");
import { useActionConfig } from "../../../hooks/useActionConfig";
const mockUseActionConfig = vi.mocked(useActionConfig);
const mockConfig: ActionConfig = {
name: "iptables",
filename: "iptables.conf",
before: null,
after: null,
actionstart: "iptables -N fail2ban-<name>",
actionstop: "iptables -F fail2ban-<name>",
actioncheck: null,
actionban: "iptables -I INPUT -s <ip> -j DROP",
actionunban: "iptables -D INPUT -s <ip> -j DROP",
actionflush: null,
definition_vars: {},
init_vars: {},
};
function renderForm(name: string) {
return render(
<FluentProvider theme={webLightTheme}>
<ActionForm name={name} />
</FluentProvider>,
);
}
describe("ActionForm", () => {
it("shows skeleton while loading", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByLabelText(/loading iptables/i)).toBeInTheDocument();
});
it("shows error message when loading fails", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: false,
error: "Timeout",
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
});
it("shows fallback error when config is null with no error message", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByText(/failed to load action config/i)).toBeInTheDocument();
});
it("renders form accordion sections when config is loaded", () => {
mockUseActionConfig.mockReturnValue({
config: mockConfig,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByText(/includes/i)).toBeInTheDocument();
expect(screen.getByText(/definition/i)).toBeInTheDocument();
});
it("passes the action name to useActionConfig", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables-multiport");
expect(mockUseActionConfig).toHaveBeenCalledWith("iptables-multiport");
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { AutoSaveIndicator } from "../AutoSaveIndicator";
function renderIndicator(props: Parameters<typeof AutoSaveIndicator>[0]) {
return render(
<FluentProvider theme={webLightTheme}>
<AutoSaveIndicator {...props} />
</FluentProvider>,
);
}
describe("AutoSaveIndicator", () => {
it("renders aria-live region when idle with no visible text", () => {
renderIndicator({ status: "idle" });
const region = screen.getByRole("status");
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute("aria-live", "polite");
// No visible text content for idle
expect(region.textContent).toBe("");
});
it("shows spinner and Saving text when saving", () => {
renderIndicator({ status: "saving" });
expect(screen.getByText(/saving/i)).toBeInTheDocument();
});
it("shows Saved badge when saved", () => {
renderIndicator({ status: "saved" });
expect(screen.getByText(/saved/i)).toBeInTheDocument();
});
it("shows error text when status is error", () => {
renderIndicator({ status: "error", errorText: "Network error" });
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
it("shows fallback error text when errorText is null", () => {
renderIndicator({ status: "error", errorText: null });
expect(screen.getByText(/save failed/i)).toBeInTheDocument();
});
it("calls onRetry when retry button is clicked", () => {
const onRetry = vi.fn();
renderIndicator({ status: "error", errorText: "Oops", onRetry });
const retryBtn = screen.getByRole("button", { name: /retry/i });
fireEvent.click(retryBtn);
expect(onRetry).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { FilterForm } from "../FilterForm";
import type { FilterConfig } from "../../../types/config";
// Mock the useFilterConfig hook so tests don't make real API calls.
vi.mock("../../../hooks/useFilterConfig");
import { useFilterConfig } from "../../../hooks/useFilterConfig";
const mockUseFilterConfig = vi.mocked(useFilterConfig);
const mockConfig: FilterConfig = {
name: "sshd",
filename: "sshd.conf",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: ["^<HOST> port \\d+"],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
};
function renderForm(name: string) {
return render(
<FluentProvider theme={webLightTheme}>
<FilterForm name={name} />
</FluentProvider>,
);
}
describe("FilterForm", () => {
it("shows skeleton while loading", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
// aria-label on Skeleton container
expect(screen.getByLabelText(/loading sshd/i)).toBeInTheDocument();
});
it("shows error message when loading fails", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: false,
error: "Network error",
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
it("shows fallback error when config is null with no error message", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
expect(screen.getByText(/failed to load filter config/i)).toBeInTheDocument();
});
it("renders form accordion sections when config is loaded", () => {
mockUseFilterConfig.mockReturnValue({
config: mockConfig,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
// Accordion headers should be visible
expect(screen.getByText(/includes/i)).toBeInTheDocument();
expect(screen.getByText(/definition/i)).toBeInTheDocument();
});
it("passes the filter name to useFilterConfig", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("nginx");
expect(mockUseFilterConfig).toHaveBeenCalledWith("nginx");
});
});

View File

@@ -0,0 +1,186 @@
/**
* Shared makeStyles definitions for the config page and its components.
*
* All config tab components import `useConfigStyles` from this module
* so that visual changes need updating in only one place.
*/
import { makeStyles, tokens } from "@fluentui/react-components";
export const useConfigStyles = makeStyles({
page: {
padding: tokens.spacingVerticalXXL,
maxWidth: "1100px",
},
header: {
marginBottom: tokens.spacingVerticalL,
},
tabContent: {
marginTop: tokens.spacingVerticalL,
animationName: "fadeInUp",
animationDuration: tokens.durationNormal,
animationTimingFunction: tokens.curveDecelerateMid,
animationFillMode: "both",
},
section: {
marginBottom: tokens.spacingVerticalXL,
},
/** Card container for form sections — adds visual separation and depth. */
sectionCard: {
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
boxShadow: tokens.shadow4,
marginBottom: tokens.spacingVerticalS,
},
/** Label row at the top of a sectionCard. */
sectionCardHeader: {
color: tokens.colorNeutralForeground2,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
paddingBottom: tokens.spacingVerticalS,
marginBottom: tokens.spacingVerticalM,
textTransform: "uppercase",
letterSpacing: "0.05em",
fontSize: tokens.fontSizeBase200,
fontWeight: tokens.fontWeightSemibold,
},
/** Monospace input with left brand-colour accent bar. */
codeInput: {
fontFamily: "monospace",
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
},
/** Applied to AccordionItem wrappers to get a hover background on headers. */
accordionItem: {
"& button:hover": {
backgroundColor: tokens.colorNeutralBackground1Hover,
},
},
/** Applied to AccordionItem wrappers that are currently expanded. */
accordionItemOpen: {
borderLeft: `3px solid ${tokens.colorBrandBackground}`,
},
fieldRow: {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: tokens.spacingHorizontalM,
marginBottom: tokens.spacingVerticalS,
"@media (max-width: 900px)": {
gridTemplateColumns: "1fr",
},
},
fieldRowThree: {
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: tokens.spacingHorizontalM,
marginBottom: tokens.spacingVerticalS,
"@media (max-width: 900px)": {
gridTemplateColumns: "1fr 1fr",
},
"@media (max-width: 700px)": {
gridTemplateColumns: "1fr",
},
},
buttonRow: {
display: "flex",
gap: tokens.spacingHorizontalS,
marginTop: tokens.spacingVerticalM,
flexWrap: "wrap",
},
codeFont: {
fontFamily: "monospace",
fontSize: "0.85rem",
},
regexItem: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
marginBottom: tokens.spacingVerticalXS,
},
regexInput: {
flexGrow: "1",
fontFamily: "monospace",
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
},
logLine: {
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
borderRadius: tokens.borderRadiusSmall,
fontFamily: "monospace",
fontSize: "0.8rem",
marginBottom: tokens.spacingVerticalXXS,
wordBreak: "break-all",
},
matched: {
backgroundColor: tokens.colorPaletteGreenBackground2,
},
notMatched: {
backgroundColor: tokens.colorNeutralBackground3,
},
previewArea: {
maxHeight: "400px",
overflowY: "auto",
padding: tokens.spacingHorizontalS,
border: `1px solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
marginTop: tokens.spacingVerticalS,
},
infoText: {
color: tokens.colorNeutralForeground3,
fontStyle: "italic",
},
/** Empty-state container: centred icon + message. */
emptyState: {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: tokens.spacingVerticalM,
padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalL}`,
color: tokens.colorNeutralForeground3,
textAlign: "center",
},
/** Auto-save status chip — for AutoSaveIndicator. */
autoSaveWrapper: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground2,
},
autoSaveSaved: {
opacity: "1",
transform: "scale(1)",
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
},
autoSaveFadingOut: {
opacity: "0",
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
},
});
/**
* Global CSS keyframes injected once.
*
* ``makeStyles`` does not support top-level ``@keyframes``, so we inject them
* via a ``<style>`` element on first import. The function is idempotent.
*/
export function injectGlobalStyles(): void {
if (document.getElementById("bangui-global-styles")) return;
const style = document.createElement("style");
style.id = "bangui-global-styles";
style.textContent = `
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
`;
document.head.appendChild(style);
}
// Inject keyframes on first module load (browser environment only).
if (typeof window !== "undefined") {
injectGlobalStyles();
}

View File

@@ -0,0 +1,28 @@
/**
* Barrel export for all config page components.
*
* Import from this module to access any component in the config package:
* import { FiltersTab, RegexList } from "../components/config";
*/
export { ActionsTab } from "./ActionsTab";
export { ActionForm } from "./ActionForm";
export type { ActionFormProps } from "./ActionForm";
export { AutoSaveIndicator } from "./AutoSaveIndicator";
export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator";
export { ConfFilesTab } from "./ConfFilesTab";
export type { ConfFilesTabProps } from "./ConfFilesTab";
export { ExportTab } from "./ExportTab";
export { FilterForm } from "./FilterForm";
export type { FilterFormProps } from "./FilterForm";
export { FiltersTab } from "./FiltersTab";
export { GlobalTab } from "./GlobalTab";
export { JailFilesTab } from "./JailFilesTab";
export { JailFileForm } from "./JailFileForm";
export { JailsTab } from "./JailsTab";
export { MapTab } from "./MapTab";
export { RegexList } from "./RegexList";
export type { RegexListProps } from "./RegexList";
export { RegexTesterTab } from "./RegexTesterTab";
export { ServerTab } from "./ServerTab";
export { useConfigStyles } from "./configStyles";

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useAutoSave } from "../useAutoSave";
describe("useAutoSave", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts with idle status", () => {
const save = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useAutoSave("initial", save));
expect(result.current.status).toBe("idle");
expect(result.current.errorText).toBeNull();
});
it("does not save immediately when value changes", () => {
const save = vi.fn().mockResolvedValue(undefined);
const { rerender } = renderHook(({ value }) => useAutoSave(value, save), {
initialProps: { value: "initial" },
});
rerender({ value: "changed" });
expect(save).not.toHaveBeenCalled();
});
it("calls save after debounce period", async () => {
const save = vi.fn().mockResolvedValue(undefined);
const { rerender } = renderHook(({ value }) => useAutoSave(value, save, { debounceMs: 500 }), {
initialProps: { value: "initial" },
});
rerender({ value: "changed" });
expect(save).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(500); });
await act(() => Promise.resolve());
expect(save).toHaveBeenCalledWith("changed");
});
it("transitions to saving then saved on success", async () => {
let resolveSave!: () => void;
const save = vi.fn().mockImplementation(
() => new Promise<void>((resolve) => { resolveSave = resolve; }),
);
const { result, rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
act(() => { vi.advanceTimersByTime(100); });
// Let the microtask queue run so the save call starts.
await act(() => Promise.resolve());
expect(result.current.status).toBe("saving");
await act(() => { resolveSave(); return Promise.resolve(); });
expect(result.current.status).toBe("saved");
});
it("transitions to error status on save failure", async () => {
const save = vi.fn().mockRejectedValue(new Error("network error"));
const { result, rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
act(() => { vi.advanceTimersByTime(100); });
// Wait for the save promise to reject and state to update.
await act(() => Promise.resolve());
await act(() => Promise.resolve());
expect(result.current.status).toBe("error");
expect(result.current.errorText).toBe("network error");
});
it("retry triggers another save attempt", async () => {
const save = vi.fn()
.mockRejectedValueOnce(new Error("first failure"))
.mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
act(() => { vi.advanceTimersByTime(100); });
await act(() => Promise.resolve());
await act(() => Promise.resolve());
expect(result.current.status).toBe("error");
await act(() => { result.current.retry(); return Promise.resolve(); });
await act(() => Promise.resolve());
expect(result.current.status).toBe("saved");
expect(save).toHaveBeenCalledTimes(2);
});
it("debounces rapid value changes — calls save only once", async () => {
const save = vi.fn().mockResolvedValue(undefined);
const { rerender } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 300 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
vi.advanceTimersByTime(100);
rerender({ value: "v3" });
vi.advanceTimersByTime(100);
rerender({ value: "v4" });
act(() => { vi.advanceTimersByTime(300); });
await act(() => Promise.resolve());
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith("v4");
});
it("clears timers on unmount", () => {
const save = vi.fn().mockResolvedValue(undefined);
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
const { rerender, unmount } = renderHook(
({ value }) => useAutoSave(value, save, { debounceMs: 500 }),
{ initialProps: { value: "v1" } },
);
rerender({ value: "v2" });
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter } from "react-router-dom";
import { ConfigPage } from "../ConfigPage";
// Mock all tab components to avoid deep render trees and API calls.
vi.mock("../../components/config", () => ({
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
GlobalTab: () => <div data-testid="global-tab">GlobalTab</div>,
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
MapTab: () => <div data-testid="map-tab">MapTab</div>,
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
}));
function renderPage() {
return render(
<MemoryRouter>
<FluentProvider theme={webLightTheme}>
<ConfigPage />
</FluentProvider>
</MemoryRouter>,
);
}
describe("ConfigPage", () => {
it("renders the Jails tab by default", () => {
renderPage();
expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
});
it("switches to Filters tab when Filters tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
expect(screen.queryByTestId("jails-tab")).not.toBeInTheDocument();
});
it("switches to Actions tab when Actions tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /actions/i }));
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
});
it("switches to Global tab when Global tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /global/i }));
expect(screen.getByTestId("global-tab")).toBeInTheDocument();
});
it("switches to Server tab when Server tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
expect(screen.getByTestId("server-tab")).toBeInTheDocument();
});
it("renders the page heading", () => {
renderPage();
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
});
});