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

@@ -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. **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/` 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. - **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.
- Allow the user to toggle inactive jails to active (and vice-versa) from the UI. - **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. ### 4b — Editable Log Paths ✅ DONE
- 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.
### 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/`. ### 4c — Audit Missing Config Options ✅ DONE
- Allow the user to activate, deactivate, view, and edit filter definitions from the UI.
- Provide an option to create a brand-new filter file.
### 4e — Action Configuration (`action.d`) - Audit findings documented above.
- List all action files in `action.d/`. ### 4d — Filter Configuration (`filter.d`) ✅ DONE
- Allow the user to activate, deactivate, view, and edit action definitions from the UI.
- Provide an option to create a brand-new action file.
### 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). ### 4e — Action Configuration (`action.d`) ✅ DONE
- Validate the new file before writing it to the config directory.
- 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, MessageBarBody,
Select, Select,
Spinner, Spinner,
Switch,
Tab, Tab,
TabList, TabList,
Text, Text,
@@ -46,12 +47,28 @@ import {
useServerSettings, useServerSettings,
} from "../hooks/useConfig"; } from "../hooks/useConfig";
import { import {
addLogPath,
createActionFile,
createFilterFile,
deleteLogPath,
fetchActionFile,
fetchActionFiles,
fetchFilterFile,
fetchFilterFiles,
fetchJailConfigFileContent,
fetchJailConfigFiles,
fetchMapColorThresholds, fetchMapColorThresholds,
setJailConfigFileEnabled,
updateActionFile,
updateFilterFile,
updateMapColorThresholds, updateMapColorThresholds,
} from "../api/config"; } from "../api/config";
import type { import type {
AddLogPathRequest,
ConfFileEntry,
GlobalConfigUpdate, GlobalConfigUpdate,
JailConfig, JailConfig,
JailConfigFile,
JailConfigUpdate, JailConfigUpdate,
MapColorThresholdsUpdate, MapColorThresholdsUpdate,
ServerSettingsUpdate, ServerSettingsUpdate,
@@ -235,9 +252,55 @@ function JailAccordionPanel({
const [maxRetry, setMaxRetry] = useState(String(jail.max_retry)); const [maxRetry, setMaxRetry] = useState(String(jail.max_retry));
const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex); const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex);
const [ignoreRegex, setIgnoreRegex] = useState<string[]>(jail.ignore_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 [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); 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 () => { const handleSave = useCallback(async () => {
setSaving(true); setSaving(true);
setMsg(null); setMsg(null);
@@ -314,17 +377,57 @@ function JailAccordionPanel({
</Field> </Field>
</div> </div>
<Field label="Log Paths"> <Field label="Log Paths">
{jail.log_paths.length === 0 ? ( {logPaths.length === 0 ? (
<Text className={styles.infoText} size={200}> <Text className={styles.infoText} size={200}>
(none) (none)
</Text> </Text>
) : ( ) : (
jail.log_paths.map((p) => ( logPaths.map((p) => (
<div key={p} className={styles.codeFont}> <div key={p} className={styles.regexItem}>
{p} <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> </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> </Field>
<div style={{ marginTop: tokens.spacingVerticalS }}> <div style={{ marginTop: tokens.spacingVerticalS }}>
<RegexList <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 // RegexTesterTab
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1114,7 +1711,15 @@ function RegexTesterTab(): React.JSX.Element {
// ConfigPage (root) // 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 { export function ConfigPage(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
@@ -1139,6 +1744,9 @@ export function ConfigPage(): React.JSX.Element {
}} }}
> >
<Tab value="jails">Jails</Tab> <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="global">Global</Tab>
<Tab value="server">Server</Tab> <Tab value="server">Server</Tab>
<Tab value="map">Map</Tab> <Tab value="map">Map</Tab>
@@ -1147,6 +1755,9 @@ export function ConfigPage(): React.JSX.Element {
<div className={styles.tabContent}> <div className={styles.tabContent}>
{tab === "jails" && <JailsTab />} {tab === "jails" && <JailsTab />}
{tab === "jailfiles" && <JailFilesTab />}
{tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />}
{tab === "global" && <GlobalTab />} {tab === "global" && <GlobalTab />}
{tab === "server" && <ServerTab />} {tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />} {tab === "map" && <MapTab />}