Add log path to jail via inline form in ConfigPage

The JailAccordionPanel previously allowed deleting log paths but
had no UI to add new ones. The backend endpoint, API helper, and
hook all existed; only the UI was missing.

Changes:
- ConfigPage.tsx: import addLogPath/AddLogPathRequest; add state
  (newLogPath, newLogPathTail, addingLogPath) and handleAddLogPath
  callback to JailAccordionPanel; render inline form below the
  log-path list with Input, Switch (tail/head), and labeled Add
  button that appends on success and surfaces errors inline.
- ConfigPageLogPath.test.tsx: 6 tests covering render, disabled
  state, enabled state, successful add, success feedback, and API
  error handling. All 33 frontend tests pass.
This commit is contained in:
2026-03-12 19:16:20 +01:00
parent 28f7b1cfcd
commit 59464a1592
3 changed files with 896 additions and 26 deletions

View File

@@ -23,6 +23,7 @@ import {
MessageBarBody,
Select,
Spinner,
Switch,
Tab,
TabList,
Text,
@@ -46,12 +47,28 @@ import {
useServerSettings,
} from "../hooks/useConfig";
import {
addLogPath,
createActionFile,
createFilterFile,
deleteLogPath,
fetchActionFile,
fetchActionFiles,
fetchFilterFile,
fetchFilterFiles,
fetchJailConfigFileContent,
fetchJailConfigFiles,
fetchMapColorThresholds,
setJailConfigFileEnabled,
updateActionFile,
updateFilterFile,
updateMapColorThresholds,
} from "../api/config";
import type {
AddLogPathRequest,
ConfFileEntry,
GlobalConfigUpdate,
JailConfig,
JailConfigFile,
JailConfigUpdate,
MapColorThresholdsUpdate,
ServerSettingsUpdate,
@@ -235,9 +252,55 @@ function JailAccordionPanel({
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 [deletingPath, setDeletingPath] = useState<string | null>(null);
const [newLogPath, setNewLogPath] = useState("");
const [newLogPathTail, setNewLogPathTail] = useState(true);
const [addingLogPath, setAddingLogPath] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
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 handleSave = useCallback(async () => {
setSaving(true);
setMsg(null);
@@ -314,17 +377,57 @@ function JailAccordionPanel({
</Field>
</div>
<Field label="Log Paths">
{jail.log_paths.length === 0 ? (
{logPaths.length === 0 ? (
<Text className={styles.infoText} size={200}>
(none)
</Text>
) : (
jail.log_paths.map((p) => (
<div key={p} className={styles.codeFont}>
{p}
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
@@ -922,6 +1025,500 @@ function MapTab(): React.JSX.Element {
);
}
// ---------------------------------------------------------------------------
// JailFilesTab — manage jail.d config files
// ---------------------------------------------------------------------------
function JailFilesTab(): React.JSX.Element {
const styles = useStyles();
const [files, setFiles] = useState<JailConfigFile[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [openItems, setOpenItems] = useState<string[]>([]);
const [fileContents, setFileContents] = useState<Record<string, string>>({});
const [toggling, setToggling] = useState<string | null>(null);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const loadFiles = useCallback(async () => {
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 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 filename of newlyOpened) {
if (!Object.prototype.hasOwnProperty.call(fileContents, filename)) {
void fetchJailConfigFileContent(filename)
.then((c) => {
setFileContents((prev) => ({ ...prev, [filename]: c.content }));
})
.catch(() => {
setFileContents((prev) => ({
...prev,
[filename]: "(failed to load)",
}));
});
}
}
},
[openItems, fileContents],
);
const handleToggleEnabled = useCallback(
async (filename: string, enabled: boolean) => {
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);
}
},
[],
);
if (loading) return <Spinner label="Loading jail config files…" />;
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 && (
<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 files found in jail.d/.
</Text>
)}
<Accordion
multiple
collapsible
openItems={openItems}
onToggle={handleAccordionToggle}
style={{ marginTop: tokens.spacingVerticalM }}
>
{files.map((file) => (
<AccordionItem key={file.filename} value={file.filename}>
<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>
{openItems.includes(file.filename) &&
(fileContents[file.filename] === undefined ? (
<Spinner size="tiny" label="Loading…" />
) : (
<Textarea
readOnly
value={fileContents[file.filename]}
rows={20}
style={{ width: "100%", resize: "vertical" }}
className={styles.codeFont}
/>
))}
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</div>
);
}
// ---------------------------------------------------------------------------
// ConfFilesTab — generic editable list for filter.d / action.d
// ---------------------------------------------------------------------------
interface ConfFilesTabProps {
label: string;
fetchList: () => Promise<{ files: ConfFileEntry[]; total: number }>;
fetchFile: (name: string) => Promise<{
name: string;
filename: string;
content: string;
}>;
updateFile: (
name: string,
req: { content: string },
) => Promise<void>;
createFile: (req: {
name: string;
content: string;
}) => Promise<{ name: string; filename: string; content: string }>;
}
function ConfFilesTab({
label,
fetchList,
fetchFile,
updateFile,
createFile,
}: ConfFilesTabProps): React.JSX.Element {
const styles = useStyles();
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>
);
}
function FiltersTab(): React.JSX.Element {
return (
<ConfFilesTab
label="Filter"
fetchList={fetchFilterFiles}
fetchFile={fetchFilterFile}
updateFile={updateFilterFile}
createFile={createFilterFile}
/>
);
}
function ActionsTab(): React.JSX.Element {
return (
<ConfFilesTab
label="Action"
fetchList={fetchActionFiles}
fetchFile={fetchActionFile}
updateFile={updateActionFile}
createFile={createActionFile}
/>
);
}
// ---------------------------------------------------------------------------
// RegexTesterTab
// ---------------------------------------------------------------------------
@@ -1114,7 +1711,15 @@ function RegexTesterTab(): React.JSX.Element {
// ConfigPage (root)
// ---------------------------------------------------------------------------
type TabValue = "jails" | "global" | "server" | "map" | "regex";
type TabValue =
| "jails"
| "jailfiles"
| "filters"
| "actions"
| "global"
| "server"
| "map"
| "regex";
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
@@ -1139,6 +1744,9 @@ export function ConfigPage(): React.JSX.Element {
}}
>
<Tab value="jails">Jails</Tab>
<Tab value="jailfiles">Jail Files</Tab>
<Tab value="filters">Filters</Tab>
<Tab value="actions">Actions</Tab>
<Tab value="global">Global</Tab>
<Tab value="server">Server</Tab>
<Tab value="map">Map</Tab>
@@ -1147,6 +1755,9 @@ export function ConfigPage(): React.JSX.Element {
<div className={styles.tabContent}>
{tab === "jails" && <JailsTab />}
{tab === "jailfiles" && <JailFilesTab />}
{tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />}
{tab === "global" && <GlobalTab />}
{tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />}