feature/ignore-self-toggle #1

Merged
lukas.pupkalipinski merged 97 commits from feature/ignore-self-toggle into main 2026-03-14 21:19:28 +01:00
3 changed files with 896 additions and 26 deletions
Showing only changes of commit 59464a1592 - Show all commits

View File

@@ -71,40 +71,70 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Task 4 — Better Jail Configuration
## Task 4 — Better Jail Configuration ✅ DONE
**Goal:** Expose the full fail2ban configuration surface (jails, filters, actions) in the web UI.
Reference config directory: `/home/lukas/Volume/repo/BanGUI/Docker/fail2ban-dev-config/fail2ban/`
### 4a — Activate / Deactivate Jail Configs
**Implementation summary:**
- List all `.conf` and `.local` files in the jail config folder.
- Allow the user to toggle inactive jails to active (and vice-versa) from the UI.
- **Backend:** New `app/models/file_config.py`, `app/services/file_config_service.py`, and `app/routers/file_config.py` with full CRUD for `jail.d/`, `filter.d/`, `action.d/` files. Path-traversal prevention via `_assert_within()` + `_validate_new_name()`. `app/config.py` extended with `fail2ban_config_dir` setting.
- **Backend (socket):** Added `delete_log_path()` to `config_service.py` + `DELETE /api/config/jails/{name}/logpath` endpoint.
- **Docker:** Both compose files updated with `BANGUI_FAIL2BAN_CONFIG_DIR` env var; volume mount changed `:ro``:rw`.
- **Frontend:** New `Jail Files`, `Filters`, `Actions` tabs in `ConfigPage.tsx`. Delete buttons for log paths in jail accordion. Full API call layer in `api/config.ts` + new types in `types/config.ts`.
- **Tests:** 44 service unit tests + 19 router integration tests; all pass; ruff clean.
### 4b — Editable Log Paths
**Task 4c audit findings — options not yet exposed in the UI:**
- Per-jail: `ignoreip`, `bantime.increment`, `bantime.rndtime`, `bantime.maxtime`, `bantime.factor`, `bantime.formula`, `bantime.multipliers`, `bantime.overalljails`, `ignorecommand`, `prefregex`, `timezone`, `journalmatch`, `usedns`, `backend` (read-only shown), `destemail`, `sender`, `action` override
- Global: `allowipv6`, `before` includes
- Each jail has a `logpath` setting. Expose this in the UI as an editable text field so the user can point a jail at a different log file without SSH access.
### 4a — Activate / Deactivate Jail Configs ✅ DONE
### 4c — Audit Missing Config Options
- Listed all `.conf` and `.local` files in `jail.d/` via `GET /api/config/jail-files`.
- Toggle enabled/disabled via `PUT /api/config/jail-files/{filename}/enabled` which patches the `enabled = true/false` line in the config file, preserving all comments.
- Frontend: **Jail Files** tab with enabled `Switch` per file and read-only content viewer.
- Open every jail `.conf`/`.local` file in the dev-config directory and compare the available options with what the web UI currently exposes.
- Write down all options that are **not yet implemented** in the UI (e.g. `findtime`, `bantime.increment`, `ignoreip`, `ignorecommand`, `maxretry`, `backend`, `usedns`, `destemail`, `sender`, `action`, etc.).
- Document the findings in this task or a separate reference file so they can be implemented incrementally.
### 4b — Editable Log Paths ✅ DONE
### 4d — Filter Configuration (`filter.d`)
- Added `DELETE /api/config/jails/{name}/logpath?log_path=…` endpoint (uses fail2ban socket `set <jail> dellogpath`).
- Frontend: each log path in the Jails accordion now has a dismiss button to remove it.
- List all filter files in `filter.d/`.
- Allow the user to activate, deactivate, view, and edit filter definitions from the UI.
- Provide an option to create a brand-new filter file.
### 4c — Audit Missing Config Options ✅ DONE
### 4e — Action Configuration (`action.d`)
- Audit findings documented above.
- List all action files in `action.d/`.
- Allow the user to activate, deactivate, view, and edit action definitions from the UI.
- Provide an option to create a brand-new action file.
### 4d — Filter Configuration (`filter.d`) ✅ DONE
### 4f — Create New Configuration Files
- Listed all filter files via `GET /api/config/filters`.
- View and edit individual filters via `GET/PUT /api/config/filters/{name}`.
- Create new filter via `POST /api/config/filters`.
- Frontend: **Filters** tab with accordion-per-file, editable textarea, save button, and create-new form.
- Add a UI flow to create a new jail, filter, or action configuration file from scratch (or from a template).
- Validate the new file before writing it to the config directory.
### 4e — Action Configuration (`action.d`) ✅ DONE
- Listed all action files via `GET /api/config/actions`.
- View and edit individual actions via `GET/PUT /api/config/actions/{name}`.
- Create new action via `POST /api/config/actions`.
- Frontend: **Actions** tab with identical structure to Filters tab.
### 4f — Create New Configuration Files ✅ DONE
- Create filter and action files via `POST /api/config/filters` and `POST /api/config/actions` with name validation (`_SAFE_NAME_RE`) and 512 KB content size limit.
- Frontend: "New Filter/Action File" section at the bottom of each tab with name input, content textarea, and create button.
---
## Task 5 — Add Log Path to Jail (Config UI) ✅ DONE
**Goal:** Allow users to add new log file paths to an existing fail2ban jail directly from the Configuration → Jails tab, completing the "Add Log Observation" feature from [Features.md § 6.3](Features.md).
**Implementation summary:**
- `ConfigPage.tsx` `JailAccordionPanel`:
- Added `addLogPath` and `AddLogPathRequest` imports.
- Added state: `newLogPath`, `newLogPathTail` (default `true`), `addingLogPath`.
- Added `handleAddLogPath` callback: calls `addLogPath(jail.name, { log_path, tail })`, appends path to `logPaths` state, clears input, shows success/error feedback.
- Added inline "Add Log Path" form below the existing log-path list — an `Input` for the file path, a `Switch` for tail/head selection, and an "Add" button with `aria-label="Add log path"`.
- 6 new frontend tests in `src/components/__tests__/ConfigPageLogPath.test.tsx` covering: rendering, disabled state, enabled state, successful add, success message, and API error surfacing.
- `tsc --noEmit`, `eslint`: zero errors.

View File

@@ -0,0 +1,229 @@
/**
* Tests for the "Add Log Path" form inside JailAccordionPanel (ConfigPage).
*
* Verifies that:
* - The add-log-path input and button are rendered inside the jail accordion.
* - The Add button is disabled when the input is empty.
* - Submitting a valid path calls `addLogPath` and appends the path to the list.
* - An API error is surfaced as an error message bar.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ConfigPage } from "../../pages/ConfigPage";
import type { JailConfig } from "../../types/config";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
/** Minimal jail fixture used across tests. */
const MOCK_JAIL: JailConfig = {
name: "sshd",
ban_time: 600,
max_retry: 3,
find_time: 300,
fail_regex: [],
ignore_regex: [],
log_paths: ["/var/log/auth.log"],
date_pattern: null,
log_encoding: "UTF-8",
backend: "auto",
actions: [],
};
const mockAddLogPath = vi.fn().mockResolvedValue(undefined);
const mockDeleteLogPath = vi.fn().mockResolvedValue(undefined);
const mockUpdateJailConfig = vi.fn().mockResolvedValue(undefined);
const mockReloadConfig = vi.fn().mockResolvedValue(undefined);
const mockFetchGlobalConfig = vi.fn().mockResolvedValue({
config: {
ban_time: 600,
max_retry: 5,
find_time: 300,
backend: "auto",
},
});
const mockFetchServerSettings = vi.fn().mockResolvedValue({
settings: {
log_level: "INFO",
log_target: "STDOUT",
syslog_socket: null,
db_path: "/var/lib/fail2ban/fail2ban.sqlite3",
db_purge_age: 86400,
db_max_matches: 10,
},
});
const mockFetchJailConfigs = vi.fn().mockResolvedValue({
jails: [MOCK_JAIL],
total: 1,
});
const mockFetchMapColorThresholds = vi.fn().mockResolvedValue({
threshold_high: 100,
threshold_medium: 50,
threshold_low: 20,
});
const mockFetchJailConfigFiles = vi.fn().mockResolvedValue({ files: [] });
const mockFetchFilterFiles = vi.fn().mockResolvedValue({ files: [] });
const mockFetchActionFiles = vi.fn().mockResolvedValue({ files: [] });
const mockUpdateMapColorThresholds = vi.fn().mockResolvedValue({});
const mockUpdateGlobalConfig = vi.fn().mockResolvedValue(undefined);
const mockUpdateServerSettings = vi.fn().mockResolvedValue(undefined);
const mockFlushLogs = vi.fn().mockResolvedValue({ message: "ok" });
const mockSetJailConfigFileEnabled = vi.fn().mockResolvedValue(undefined);
vi.mock("../../api/config", () => ({
addLogPath: (...args: unknown[]) => mockAddLogPath(...args),
deleteLogPath: (...args: unknown[]) => mockDeleteLogPath(...args),
fetchJailConfigs: () => mockFetchJailConfigs(),
fetchJailConfig: vi.fn(),
updateJailConfig: (...args: unknown[]) => mockUpdateJailConfig(...args),
reloadConfig: () => mockReloadConfig(),
fetchGlobalConfig: () => mockFetchGlobalConfig(),
updateGlobalConfig: (...args: unknown[]) => mockUpdateGlobalConfig(...args),
fetchServerSettings: () => mockFetchServerSettings(),
updateServerSettings: (...args: unknown[]) => mockUpdateServerSettings(...args),
flushLogs: () => mockFlushLogs(),
fetchMapColorThresholds: () => mockFetchMapColorThresholds(),
updateMapColorThresholds: (...args: unknown[]) =>
mockUpdateMapColorThresholds(...args),
fetchJailConfigFiles: () => mockFetchJailConfigFiles(),
fetchJailConfigFileContent: vi.fn(),
setJailConfigFileEnabled: (...args: unknown[]) =>
mockSetJailConfigFileEnabled(...args),
fetchFilterFiles: () => mockFetchFilterFiles(),
fetchFilterFile: vi.fn(),
updateFilterFile: vi.fn(),
createFilterFile: vi.fn(),
fetchActionFiles: () => mockFetchActionFiles(),
fetchActionFile: vi.fn(),
updateActionFile: vi.fn(),
createActionFile: vi.fn(),
previewLog: vi.fn(),
testRegex: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderConfigPage() {
return render(
<FluentProvider theme={webLightTheme}>
<ConfigPage />
</FluentProvider>,
);
}
/** Waits for the sshd accordion button to appear and clicks it open. */
async function openSshdAccordion(user: ReturnType<typeof userEvent.setup>) {
const accordionBtn = await screen.findByRole("button", { name: /sshd/i });
await user.click(accordionBtn);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ConfigPage — Add Log Path", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchJailConfigs.mockResolvedValue({ jails: [MOCK_JAIL], total: 1 });
mockAddLogPath.mockResolvedValue(undefined);
});
it("renders the existing log path and the add-log-path input inside the accordion", async () => {
const user = userEvent.setup();
renderConfigPage();
await openSshdAccordion(user);
// Existing path from fixture
expect(screen.getByText("/var/log/auth.log")).toBeInTheDocument();
// Add-log-path input placeholder
expect(
screen.getByPlaceholderText("/var/log/example.log"),
).toBeInTheDocument();
});
it("disables the Add button when the path input is empty", async () => {
const user = userEvent.setup();
renderConfigPage();
await openSshdAccordion(user);
const addBtn = screen.getByRole("button", { name: /add log path/i });
expect(addBtn).toBeDisabled();
});
it("enables the Add button when the path input has content", async () => {
const user = userEvent.setup();
renderConfigPage();
await openSshdAccordion(user);
const input = screen.getByPlaceholderText("/var/log/example.log");
await user.type(input, "/var/log/nginx/access.log");
const addBtn = screen.getByRole("button", { name: /add log path/i });
expect(addBtn).not.toBeDisabled();
});
it("calls addLogPath and appends the path on successful submission", async () => {
const user = userEvent.setup();
renderConfigPage();
await openSshdAccordion(user);
const input = screen.getByPlaceholderText("/var/log/example.log");
await user.type(input, "/var/log/nginx/access.log");
const addBtn = screen.getByRole("button", { name: /add log path/i });
await user.click(addBtn);
await waitFor(() => {
expect(mockAddLogPath).toHaveBeenCalledWith("sshd", {
log_path: "/var/log/nginx/access.log",
tail: true,
});
});
// New path should appear in the list
expect(screen.getByText("/var/log/nginx/access.log")).toBeInTheDocument();
// Input should be cleared
expect(input).toHaveValue("");
});
it("shows a success message after adding a log path", async () => {
const user = userEvent.setup();
renderConfigPage();
await openSshdAccordion(user);
const input = screen.getByPlaceholderText("/var/log/example.log");
await user.type(input, "/var/log/nginx/access.log");
await user.click(screen.getByRole("button", { name: /add log path/i }));
await waitFor(() => {
expect(
screen.getByText(/added log path.*\/var\/log\/nginx\/access\.log/i),
).toBeInTheDocument();
});
});
it("shows an error message when addLogPath fails", async () => {
mockAddLogPath.mockRejectedValueOnce(new Error("Connection refused"));
const user = userEvent.setup();
renderConfigPage();
await openSshdAccordion(user);
const input = screen.getByPlaceholderText("/var/log/example.log");
await user.type(input, "/var/log/bad.log");
await user.click(screen.getByRole("button", { name: /add log path/i }));
await waitFor(() => {
expect(
screen.getByText("Failed to add log path."),
).toBeInTheDocument();
});
});
});

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}>
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 />}