feature/ignore-self-toggle #1
@@ -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.
|
||||||
|
|||||||
229
frontend/src/components/__tests__/ConfigPageLogPath.test.tsx
Normal file
229
frontend/src/components/__tests__/ConfigPageLogPath.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 />}
|
||||||
|
|||||||
Reference in New Issue
Block a user