Implement tasks 1-3: sidebar order, jail activation rollback, pie chart colors

Task 1: Move Configuration to last position in sidebar NAV_ITEMS

Task 2: Add automatic rollback when jail activation fails
- Back up .local override file before writing
- Restore original file (or delete) on reload failure, health-check
  failure, or jail not appearing post-reload
- Return recovered=True/False in JailActivationResponse
- Show warning/critical banner in ActivateJailDialog based on recovery
- Add _restore_local_file_sync and _rollback_activation_async helpers
- Add 3 new tests: rollback on reload failure, health-check failure,
  and double failure (recovered=False)

Task 3: Color pie chart legend labels to match their slice color
- legendFormatter now returns ReactNode with span style={{ color }}
- Import LegendPayload from recharts/types/component/DefaultLegendContent
This commit was merged in pull request #1.
This commit is contained in:
2026-03-14 21:16:58 +01:00
parent 6bb38dbd8c
commit 4be2469f92
8 changed files with 449 additions and 581 deletions

View File

@@ -4,599 +4,97 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Task 1 — Remove "Currently Banned IPs" section from the Jails page
## Task 1 — Move "Configuration" to the Last Position in the Sidebar ✅ DONE
**Status:** done
**Summary:** Moved the `Configuration` entry in `NAV_ITEMS` to the last position in `frontend/src/layouts/MainLayout.tsx`.
**Summary:** Removed `ActiveBansSection` component, `buildBanColumns` helper, `fmtTimestamp` helper, `ActiveBan` type import, Dialog/DeleteRegular/DismissRegular imports from `JailsPage.tsx`. Updated file-level and component-level JSDoc to say "three sections". `useActiveBans` kept for `banIp`/`unbanIp` used by `BanUnbanForm`.
**File:** `frontend/src/layouts/MainLayout.tsx`
**Page:** `/jails` — rendered by `frontend/src/pages/JailsPage.tsx`
The `NAV_ITEMS` array (around line 183) defines the sidebar menu order. Currently the order is: Dashboard, World Map, Jails, **Configuration**, History, Blocklists. Move the Configuration entry so it is the **last** element in the array. The resulting order must be:
The Jails page currently has four sections: Jail Overview, Ban / Unban IP, **Currently Banned IPs**, and IP Lookup. Remove the "Currently Banned IPs" section entirely.
1. Dashboard
2. World Map
3. Jails
4. History
5. Blocklists
6. Configuration
### What to do
1. In `frontend/src/pages/JailsPage.tsx`, find the `ActiveBansSection` sub-component (the function that renders the "Currently Banned IPs" heading, the DataGrid of active bans, the refresh/clear-all buttons, and the confirmation dialog). Delete the entire `ActiveBansSection` function.
2. In the `JailsPage` component at the bottom of the same file, remove the `<ActiveBansSection />` JSX element from the returned markup.
3. Remove any imports, hooks, types, or helper functions that were **only** used by `ActiveBansSection` and are now unused (e.g. `useActiveBans` if it is no longer referenced elsewhere, the `buildBanColumns` helper, the `ActiveBan` type import, etc.). Check the remaining code — `useActiveBans` is also destructured for `banIp`/`unbanIp` in `JailsPage`, so keep it if still needed there.
4. Update the file-level JSDoc comment at the top of the file: change the list from four sections to three and remove the "Currently Banned IPs" bullet.
5. Update the JSDoc on the `JailsPage` component to say "three sections" instead of "four sections".
### Verification
- `npx tsc --noEmit` passes with no errors.
- `npx eslint src/pages/JailsPage.tsx` reports no warnings.
- The `/jails` page renders without the "Currently Banned IPs" section; the remaining three sections (Jail Overview, Ban/Unban IP, IP Lookup) still work.
Only the position in the array changes. Do not modify the label, path, or icon of any item.
---
## Task 2 — Remove "Jail Distribution" section from the Dashboard
## Task 2 — Auto-Recovery When Jail Activation Fails ✅ DONE
**Status:** done
**Summary:** Added `recovered: bool | None` field to `JailActivationResponse` model. Implemented `_restore_local_file_sync` and `_rollback_activation_async` helpers. Updated `activate_jail` to back up the original `.local` file, roll back on any post-write failure (reload error, health-check failure, or jail not starting), and return `recovered=True/False`. Updated `ActivateJailDialog.tsx` to show warning/critical banners based on the `recovered` field. Added 3 new backend tests covering all rollback scenarios.
**Summary:** Removed `JailDistributionChart` import and JSX block from `DashboardPage.tsx`. The component file is retained (still importable) but no longer rendered.
**Context:** When a user activates a jail via `POST /api/config/jails/{name}/activate`, the backend writes `enabled = true` to `jail.d/{name}.local` and then reloads fail2ban. If the new configuration is invalid or the server crashes after reload, fail2ban stays broken and all jails go offline. The system must automatically recover by rolling back the change and restarting fail2ban.
**Page:** `/` (Dashboard) — rendered by `frontend/src/pages/DashboardPage.tsx`
### Backend Changes
The Dashboard currently shows: Server Status Bar, Filter Bar, Ban Trend, Top Countries, **Jail Distribution**, and Ban List. Remove the "Jail Distribution" section.
**File:** `backend/app/services/config_file_service.py``activate_jail()` method (around line 1086)
### What to do
Wrap the reload-and-verify sequence in error handling that performs a rollback on failure:
1. In `frontend/src/pages/DashboardPage.tsx`, delete the entire `{/* Jail Distribution section */}` block — this is the `<div className={styles.section}>` that wraps the `<JailDistributionChart>` component (approximately lines 164177).
2. Remove the `JailDistributionChart` import at the top of the file (`import { JailDistributionChart } from "../components/JailDistributionChart";`).
3. If the `JailDistributionChart` component file (`frontend/src/components/JailDistributionChart.tsx`) is not imported anywhere else in the codebase, you may optionally delete it, but this is not required.
1. **Before writing** the `.local` override file, check whether a `.local` file for that jail already exists. If it does, read and keep its content in memory as a backup. If it does not exist, remember that no file existed.
2. **Write** the override file with `enabled = true` (existing logic).
3. **Reload** fail2ban via `jail_service.reload_all()` (existing logic).
4. **Health-check / verify** that fail2ban is responsive and the jail appears in the active list (existing logic).
5. **If any step after the write fails** (reload error, health-check timeout, jail not appearing):
- **Rollback the config**: restore the original `.local` file content (or delete the file if it did not exist before).
- **Restart fail2ban**: call `jail_service.reload_all()` again so fail2ban recovers with the old configuration.
- **Health-check again** to confirm fail2ban is back.
- Return an appropriate error response (HTTP 502 or 500) with a message that explains the activation failed **and** the system was recovered. Include a field `recovered: true` in the JSON body so the frontend can display a recovery notice.
6. If rollback itself fails, return an error with `recovered: false` so the frontend can display a critical alert.
### Verification
**File:** `backend/app/routers/config.py``activate_jail` endpoint (around line 584)
- `npx tsc --noEmit` passes with no errors.
- `npx eslint src/pages/DashboardPage.tsx` reports no warnings.
- The Dashboard renders without the "Jail Distribution" chart; all other sections remain functional.
Propagate the `recovered` field in the error response. No extra logic is needed in the router if the service already raises an appropriate exception or returns a result object with the recovery status.
### Frontend Changes
**File:** `frontend/src/components/config/JailsTab.tsx` (or wherever the activate mutation result is handled)
When the activation API call returns an error:
- If `recovered` is `true`, show a warning banner/toast: *"Activation of jail '{name}' failed. The server has been automatically recovered."*
- If `recovered` is `false`, show a critical error banner/toast: *"Activation of jail '{name}' failed and automatic recovery was unsuccessful. Manual intervention is required."*
### Tests
Add or extend tests in `backend/tests/test_services/test_config_file_service.py`:
- **test_activate_jail_rollback_on_reload_failure**: Mock `jail_service.reload_all()` to raise on the first call (activation reload) and succeed on the second call (recovery reload). Assert the `.local` file is restored to its original content and the response indicates `recovered: true`.
- **test_activate_jail_rollback_on_health_check_failure**: Mock the health check to fail after reload. Assert rollback and recovery.
- **test_activate_jail_rollback_failure**: Mock both the activation reload and the recovery reload to fail. Assert the response indicates `recovered: false`.
---
## Task 3 — Fix transparent pie chart in "Top Countries" on the Dashboard
## Task 3 — Match Pie Chart Slice Colors to Country Label Font Colors ✅ DONE
**Status:** done
**Summary:** Updated `legendFormatter` in `TopCountriesPieChart.tsx` to return `React.ReactNode` instead of `string`, using `<span style={{ color: entry.color }}>` to colour each legend label to match its pie slice. Imported `LegendPayload` from `recharts/types/component/DefaultLegendContent`.
**Summary:** Added `Cell` import from recharts and rendered per-slice `<Cell key={index} fill={slice.fill} />` children inside `<Pie>` in `TopCountriesPieChart.tsx`. Added `// eslint-disable-next-line @typescript-eslint/no-deprecated` since recharts v3 type-marks `Cell` as deprecated but it remains the correct mechanism for per-slice colours.
**Context:** The dashboard's Top Countries pie chart (`frontend/src/components/TopCountriesPieChart.tsx`) uses a color palette from `frontend/src/utils/chartTheme.ts` for the pie slices. The country names displayed in the legend next to the chart currently use the default text color. They should instead use the **same color as their corresponding pie slice**.
**Page:** `/` (Dashboard) — pie chart rendered by `frontend/src/components/TopCountriesPieChart.tsx`
### Changes
The pie chart in the "Top Countries" section is transparent (invisible slices). The root cause is that each slice's `fill` colour is set on the data objects but the `<Pie>` component does not apply them. Recharts needs either a `fill` prop on `<Pie>`, per-slice `<Cell>` elements, or the data items' `fill` field to be picked up correctly.
**File:** `frontend/src/components/TopCountriesPieChart.tsx`
### What to do
In the `<Legend>` component (rendered by Recharts), the `formatter` prop already receives the legend entry value. Apply a custom renderer so each country name is rendered with its matching slice color as the **font color**. The Recharts `<Legend>` accepts a `formatter` function whose second argument is the entry object containing the `color` property. Use that color to wrap the text in a `<span>` with `style={{ color: entry.color }}`. Example:
1. Open `frontend/src/components/TopCountriesPieChart.tsx`.
2. The `buildSlices` helper already attaches a resolved `fill` colour string to every `SliceData` item. However, the `<Pie>` element does not render individual `<Cell>` elements to apply those colours.
3. Import `Cell` from `recharts`:
```tsx
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
```
4. Inside the `<Pie>` element, add child `<Cell>` elements that map each slice to its colour:
```tsx
<Pie
data={slices}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
label={…}
labelLine={false}
>
{slices.map((slice, index) => (
<Cell key={index} fill={slice.fill} />
))}
</Pie>
```
This ensures each pie slice is painted with the colour from `CHART_PALETTE` that `buildSlices` resolved.
```tsx
formatter={(value: string, entry: LegendPayload) => {
const slice = slices.find((s) => s.name === value);
if (slice == null || total === 0) return value;
const pct = ((slice.value / total) * 100).toFixed(1);
return (
<span style={{ color: entry.color }}>
{value} ({pct}%)
</span>
);
}}
```
### Verification
Make sure the `formatter` return type is `ReactNode` (not `string`). Import the Recharts `Payload` type if needed: `import type { Payload } from "recharts/types/component/DefaultLegendContent"` . Adjust the import path to match the Recharts version in the project.
- `npx tsc --noEmit` passes with no errors.
- The pie chart on the Dashboard now displays coloured slices (blue, red, green, gold, purple) matching the Fluent palette.
- The legend and tooltip still work and show correct country names and percentages.
Do **not** change the pie slice colors themselves — only the country label font color must match the slice it corresponds to.
---
## Task 4 — Fix log viewer rejecting fail2ban log path under `/config/log`
**Status:** done
**Summary:** Added `"/config/log"` to `_SAFE_LOG_PREFIXES` tuple in `config_service.py` and updated the error message to reference both allowed prefixes (`/var/log` and `/config/log`). All existing tests continue to pass.
**Error:** `API error 400: {"detail":"Log path '/config/log/fail2ban/fail2ban.log' is outside the allowed directory. Only paths under /var/log are permitted."}`
**Root cause:** The linuxserver/fail2ban Docker image writes its own log to `/config/log/fail2ban/fail2ban.log` (this is configured via `logtarget` in `Docker/fail2ban-dev-config/fail2ban/fail2ban.conf`). In the Docker Compose setup, the `/config` volume is shared between the fail2ban and backend containers, so the file exists and is readable. However, the backend's `read_fail2ban_log` function in `backend/app/services/config_service.py` hard-codes the allowed path prefix list as:
```python
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log",)
```
This causes any log target under `/config/log/` to be rejected with a 400 error.
### What to do
1. Open `backend/app/services/config_service.py`.
2. Find the `_SAFE_LOG_PREFIXES` constant (line ~771). Add `"/config/log"` as a second allowed prefix:
```python
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
```
This is safe because:
- `/config/log` is a volume mount controlled by the Docker Compose setup, not user input.
- The path is still validated via `Path.resolve()` to prevent traversal (e.g. `/config/log/../../etc/shadow` resolves outside and is rejected).
3. Update the error message on the next line (inside `read_fail2ban_log`) to reflect both allowed directories:
```python
"Only paths under /var/log or /config/log are permitted."
```
4. Update the existing test `test_path_outside_safe_dir_raises_operation_error` in `backend/tests/test_services/test_config_service.py` — it currently patches `_SAFE_LOG_PREFIXES` to `("/var/log",)` in the assertion, so it will still pass. Verify no other tests break.
### Verification
- `python -m pytest backend/tests/test_services/test_config_service.py -q` passes.
- The log viewer page in the UI successfully loads the fail2ban log file at `/config/log/fail2ban/fail2ban.log` without a 400 error.
---
## Task 5 — Harden jail activation: block on missing logpaths and improve post-activation health checks
**Status:** done
**Summary:**
- **Part A** (`config_file_service.py`): `activate_jail` now refuses to proceed when `_validate_jail_config_sync` returns any issue with `field` in `("filter", "logpath")`. Returns `active=False, fail2ban_running=True` with a descriptive message without writing to disk or reloading fail2ban.
- **Part B** (`ActivateJailDialog.tsx`): All validation issues are now blocking (`blockingIssues = validationIssues`); the advisory-only `logpath` exclusion was removed.
- **Part C** (`config.py` router): After activation, `await _run_probe(request.app)` is called immediately to refresh the cached fail2ban status.
- **Part D** (`health.py`): `/api/health` now returns `{"status": "ok", "fail2ban": "online"|"offline"}` from cached probe state.
- **Tests**: Added `TestActivateJailBlocking` (3 tests) in `test_config_file_service.py`; added `test_200_with_active_false_on_missing_logpath` in `test_config.py`; updated 5 existing `TestActivateJail`/`TestActivateJailReloadArgs` tests to mock `_validate_jail_config_sync`.
### Problem description
Activating a jail whose `logpath` references a non-existent file (e.g. `airsonic-auth.conf` referencing a log file that doesn't exist) causes **fail2ban to crash entirely**. All previously running jails go down (Active Jails drops from 2 → 0). The GUI still shows "Service Health: Running" because:
1. The **backend `/api/health`** endpoint (`backend/app/routers/health.py`) only checks that the FastAPI process itself is alive — it does **not** probe fail2ban. So Docker health checks pass even when fail2ban is dead.
2. The **background health probe** runs every 30 seconds. If the user checks the dashboard during the window between the crash and the next probe, the cached status is stale (still shows "Running").
3. The **pre-activation validation** (`_validate_jail_config_sync` in `backend/app/services/config_file_service.py`) treats missing log paths as **warnings only** (`field="logpath"` issues). The `ActivateJailDialog` frontend component filters these as "advisory" and does not block activation. This means a jail with a non-existent log file is activated, causing fail2ban to crash on reload.
### What to do
#### Part A — Block activation when log files don't exist (backend)
**File:** `backend/app/services/config_file_service.py`
1. In `_validate_jail_config_sync()` (around line 715723), change the log path existence check from a **warning** to an **error** for literal, non-glob paths. Currently it appends a `JailValidationIssue` with `field="logpath"` but the function still returns `valid=True` if these are the only issues. Instead, treat a missing logpath as a blocking validation failure — the `valid` field at the bottom (line ~729) already uses `len(issues) == 0`, so if the issue is appended it will set `valid=False`.
The current code already does this correctly — the issue is in `activate_jail()` itself. Find the post-validation block (around line 11301138) where `warnings` are collected. Currently activation **always proceeds** regardless of validation result. Change this:
2. In `activate_jail()`, after running `_validate_jail_config_sync`, check `validation_result.valid`. If it is `False` **and** any issue has `field` in `("filter", "logpath")`, **refuse to activate**. Return a `JailActivationResponse` with `active=False`, `fail2ban_running=True`, and a descriptive `message` listing the blocking issues. This prevents writing `enabled = true` and reloading fail2ban with a known-bad config.
```python
# Block activation on critical validation failures (missing filter or logpath).
blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")]
if blocking:
log.warning("jail_activation_blocked", jail=name, issues=[str(i) for i in blocking])
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
validation_warnings=[f"{i.field}: {i.message}" for i in validation_result.issues],
message=(
f"Jail {name!r} cannot be activated: "
+ "; ".join(i.message for i in blocking)
),
)
```
#### Part B — Block activation in the frontend dialog
**File:** `frontend/src/components/config/ActivateJailDialog.tsx`
Currently `blockingIssues` is computed by filtering **out** `logpath` issues (line ~175):
```tsx
const blockingIssues = validationIssues.filter((i) => i.field !== "logpath");
```
Change this so that `logpath` issues **are** blocking too:
```tsx
const blockingIssues = validationIssues.filter(
(i) => i.field !== "logpath" || i.message.includes("not found"),
);
```
Or simply remove the `logpath` exclusion entirely so all validation issues block:
```tsx
const blockingIssues = validationIssues; // all issues block activation
const advisoryIssues: JailValidationIssue[] = []; // nothing is advisory anymore
```
The "Activate" button should remained disabled when `blockingIssues.length > 0` (this logic already exists).
#### Part C — Run an immediate health probe after activation (backend)
**File:** `backend/app/routers/config.py` — `activate_jail` endpoint (around line 640660)
After `config_file_service.activate_jail()` returns, **trigger an immediate health check** so the cached status is updated right away (instead of waiting up to 30 seconds):
```python
# Force an immediate health probe to refresh cached status.
from app.tasks.health_check import _run_probe
await _run_probe(request.app)
```
Add this right after the `last_activation` recording block (around line 653), before the `return result`. This ensures the dashboard immediately reflects the current fail2ban state.
#### Part D — Include fail2ban liveness in `/api/health` endpoint
**File:** `backend/app/routers/health.py`
The current health endpoint always returns `{"status": "ok"}`. Enhance it to also report fail2ban status from the cached probe:
```python
@router.get("/health", summary="Application health check")
async def health_check(request: Request) -> JSONResponse:
cached: ServerStatus = getattr(
request.app.state, "server_status", ServerStatus(online=False)
)
return JSONResponse(content={
"status": "ok",
"fail2ban": "online" if cached.online else "offline",
})
```
Keep the HTTP status code as 200 so Docker health checks don't restart the backend container when fail2ban is down. But having `"fail2ban": "offline"` in the response allows monitoring and debugging.
### Tests to add or update
**File:** `backend/tests/test_services/test_config_file_service.py`
1. **Add test**: `test_activate_jail_blocked_when_logpath_missing` — mock `_validate_jail_config_sync` to return `valid=False` with a `logpath` issue. Assert `activate_jail()` returns `active=False` and `fail2ban_running=True` without calling `reload_all`.
2. **Add test**: `test_activate_jail_blocked_when_filter_missing` — same pattern but with a `filter` issue.
3. **Add test**: `test_activate_jail_proceeds_when_only_regex_warnings` — mock validation with a non-blocking `failregex` issue and assert activation still proceeds.
**File:** `backend/tests/test_routers/test_config.py`
4. **Add test**: `test_activate_returns_400_style_response_on_missing_logpath` — POST to `/api/config/jails/{name}/activate` with a jail that has a missing logpath. Assert the response body has `active=False` and contains the logpath error message.
**File:** `backend/tests/test_tasks/test_health_check.py`
5. **Existing tests** should still pass — `_run_probe` behavior is unchanged.
### Verification
1. Run backend tests:
```bash
.venv/bin/python -m pytest backend/tests/ -q --tb=short
```
All tests pass with no failures.
2. Run frontend type check and lint:
```bash
cd frontend && npx tsc --noEmit && npx eslint src/components/config/ActivateJailDialog.tsx
```
3. **Manual test with running server:**
- Go to `/config`, find a jail with a non-existent logpath (e.g. `airsonic-auth`).
- Click "Activate" — the dialog should show a **blocking error** about the missing log file and the Activate button should be disabled.
- Verify that fail2ban is still running with the original jails intact (Active Jails count unchanged).
- Go to dashboard — "Service Health" should correctly reflect the live fail2ban state.
---
## Task 6 — Run immediate health probe after jail deactivation
**Status:** done
**Summary:** `deactivate_jail` endpoint in `config.py` now captures the service result, calls `await _run_probe(request.app)`, and then returns the result — matching the behaviour added to `activate_jail` in Task 5. Added `test_deactivate_triggers_health_probe` to `TestDeactivateJail` in `test_config.py` (verifies `_run_probe` is awaited once on success). Also fixed 3 pre-existing ruff UP017 warnings (`datetime.timezone.utc` → `datetime.UTC`) in `test_config.py`.
The `deactivate_jail` endpoint in `backend/app/routers/config.py` is inconsistent with `activate_jail`: after activation the router calls `await _run_probe(request.app)` to immediately refresh the cached fail2ban status (added in Task 5 Part C). Deactivation performs a full `reload_all` which also causes a brief fail2ban restart; without the probe the dashboard can show a stale active-jail count for up to 30 seconds.
### What to do
**File:** `backend/app/routers/config.py` — `deactivate_jail` endpoint (around line 670698)
1. The handler currently calls `config_file_service.deactivate_jail(...)` and returns its result directly via `return await ...`. Refactor it to capture the result first, run the probe, then return:
```python
result = await config_file_service.deactivate_jail(config_dir, socket_path, name)
# Force an immediate health probe so the cached status reflects the current
# fail2ban state (reload changes the active-jail count).
await _run_probe(request.app)
return result
```
`_run_probe` is already imported at the top of the file (added in Task 5).
### Tests to add or update
**File:** `backend/tests/test_routers/test_config.py`
2. **Add test**: `test_deactivate_triggers_health_probe` — in the `TestDeactivateJail` class, mock both `config_file_service.deactivate_jail` and `app.routers.config._run_probe`. POST to `/api/config/jails/sshd/deactivate` and assert that `_run_probe` was awaited exactly once.
3. **Update test** `test_200_deactivates_jail` — it already passes without the probe mock, so no changes are needed unless the test client setup causes `_run_probe` to raise. Add a mock for `_run_probe` to prevent real socket calls in that test too.
### Verification
1. Run backend tests:
```bash
.venv/bin/python -m pytest backend/tests/test_routers/test_config.py -q --tb=short
```
All tests pass with no failures.
2. Run the full backend suite to confirm no regressions:
```bash
.venv/bin/python -m pytest backend/tests/ --no-cov --tb=no -q
```
3. **Manual test with running server:**
- Go to `/config`, find an active jail and click "Deactivate".
- Immediately navigate to the Dashboard — "Active Jails" count should already reflect the reduced count without any delay.
---
## Task 7 — Fix ActivateJailDialog not honouring backend rejection and mypy false positive
**Status:** done
**Summary:**
- **Bug 1** (`ActivateJailDialog.tsx`): Added `|| blockingIssues.length > 0` to the "Activate" button's `disabled` prop so the button is correctly greyed-out when pre-validation surfaces any blocking issue (filter or logpath problems).
- **Bug 2** (`ActivateJailDialog.tsx`): `handleConfirm`'s `.then()` handler now checks `result.active` first. When `active=false` the dialog stays open and shows `result.message` as an error; `resetForm()` and `onActivated()` are only called on `active=true`.
- **Bug 3** (`config.py`): Added `# type: ignore[call-arg]` with a comment to `Settings()` call to suppress the mypy strict-mode false positive caused by pydantic-settings loading required fields from environment variables at runtime.
- **Tests**: Added `ActivateJailDialog.test.tsx` with 5 tests (button disabled on blocking issues, button enabled on clean validation, dialog stays open on backend rejection, `onActivated` called on success, `onCrashDetected` fired when `fail2ban_running=false`).
### Problem description
Two independent bugs were introduced during Tasks 56:
**Bug 1 — "Activate" button is never disabled on validation errors (frontend)**
In `frontend/src/components/config/ActivateJailDialog.tsx`, Task 5 Part B set:
```tsx
const blockingIssues = validationIssues; // all issues block activation
```
but the "Activate" `<Button>` `disabled` prop was never updated to include `blockingIssues.length > 0`:
```tsx
disabled={submitting || validating} // BUG: missing `|| blockingIssues.length > 0`
```
The pre-validation error message renders correctly, but the button stays clickable. A user can press "Activate" despite seeing a red error — the backend will refuse (returning `active=false`) but the UX is broken and confusing.
**Bug 2 — Dialog closes and fires `onActivated()` even when backend rejects activation (frontend)**
`handleConfirm`'s `.then()` handler never inspects `result.active`. When the backend blocks activation and returns `{ active: false, message: "...", validation_warnings: [...] }`, the frontend still:
1. Calls `setValidationWarnings(result.validation_warnings)` — sets warnings in state.
2. Immediately calls `resetForm()` — which **clears** the newly-set warnings.
3. Calls `onActivated()` — which triggers the parent to refresh the jail list (and may close the dialog).
The user sees the dialog briefly appear to succeed, the parent refreshes, but the jail never activated.
**Bug 3 — mypy strict false positive in `config.py`**
`get_settings()` calls `Settings()` without arguments. mypy strict mode flags this as:
```
backend/app/config.py:88: error: Missing named argument "session_secret" for "Settings" [call-arg]
```
This is a known pydantic-settings limitation: the library loads required fields from environment variables at runtime, which mypy cannot see statically. A targeted suppression with an explanatory comment is the correct fix.
### What to do
#### Part A — Disable "Activate" button when blocking issues are present (frontend)
**File:** `frontend/src/components/config/ActivateJailDialog.tsx`
Find the "Activate" `<Button>` near the bottom of the returned JSX and change its `disabled` prop:
```tsx
// Before:
disabled={submitting || validating}
// After:
disabled={submitting || validating || blockingIssues.length > 0}
```
#### Part B — Handle `active=false` response from backend (frontend)
**File:** `frontend/src/components/config/ActivateJailDialog.tsx`
In `handleConfirm`'s `.then()` callback, add a check for `result.active` before calling `resetForm()` and `onActivated()`:
```tsx
.then((result) => {
if (!result.active) {
// Backend rejected the activation (e.g. missing logpath).
// Show the server's message and keep the dialog open.
setError(result.message);
return;
}
if (result.validation_warnings.length > 0) {
setValidationWarnings(result.validation_warnings);
}
resetForm();
if (!result.fail2ban_running) {
onCrashDetected?.();
}
onActivated();
})
```
#### Part C — Fix mypy false positive (backend)
**File:** `backend/app/config.py`
Add a targeted `# type: ignore[call-arg]` with an explanatory comment to the `Settings()` call in `get_settings()`:
```python
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars
```
### Tests to add or update
**File:** `frontend/src/components/config/__tests__/ActivateJailDialog.test.tsx` (new file)
Write tests covering:
1. **`test_activate_button_disabled_when_blocking_issues`** — render the dialog with mocked `validateJailConfig` returning an issue with `field="logpath"`. Assert the "Activate" button is disabled.
2. **`test_activate_button_enabled_when_no_issues`** — render the dialog with mocked `validateJailConfig` returning no issues. Assert the "Activate" button is enabled after validation completes.
3. **`test_dialog_stays_open_when_backend_returns_active_false`** — mock `activateJail` to return `{ active: false, message: "Jail cannot be activated", validation_warnings: [], fail2ban_running: true, name: "test" }`. Click "Activate". Assert: (a) `onActivated` is NOT called; (b) the error message text appears.
4. **`test_dialog_calls_on_activated_when_backend_returns_active_true`** — mock `activateJail` to return `{ active: true, message: "ok", validation_warnings: [], fail2ban_running: true, name: "test" }`. Click "Activate". Assert `onActivated` is called once.
5. **`test_crash_detected_callback_fires_when_fail2ban_not_running`** — mock `activateJail` to return `active: true, fail2ban_running: false`. Assert `onCrashDetected` is called.
### Verification
1. Run frontend type check and lint:
```bash
cd frontend && npx tsc --noEmit && npx eslint src/components/config/ActivateJailDialog.tsx
```
Zero errors and zero warnings.
2. Run frontend tests:
```bash
cd frontend && npx vitest run src/components/config/__tests__/ActivateJailDialog
```
All 5 new tests pass.
3. Run mypy:
```bash
.venv/bin/mypy backend/app/ --strict
```
Zero errors.
---
## Task 8 — Add "ignore self" toggle to Jail Detail page
**Status:** done
**Summary:** Added `jailIgnoreSelf` endpoint constant to `endpoints.ts`; added `toggleIgnoreSelf(name, on)` API function to `jails.ts`; extended `useJailDetail` return type and hook implementation to expose `toggleIgnoreSelf`; replaced the read-only "ignore self" badge in `IgnoreListSection` (`JailDetailPage.tsx`) with a Fluent UI `Switch` that calls the toggle action and surfaces any error in the existing `opError` message bar; added 5 new tests in `JailDetailIgnoreSelf.test.tsx` covering checked/unchecked rendering, toggle-on, toggle-off, and error display.
**Page:** `/jails/:name` — rendered by `frontend/src/pages/JailDetailPage.tsx`
### Problem description
`Features.md` §5 (Jail Management / IP Whitelist) requires: "Toggle the 'ignore self' option per jail, which automatically excludes the server's own IP addresses."
The backend already exposes `POST /api/jails/{name}/ignoreself` (accepts a JSON boolean `on`). The `useJailDetail` hook reads `ignore_self` from the `GET /api/jails/{name}` response and exposes it as `ignoreSelf: boolean`. However:
1. **No API wrapper** — `frontend/src/api/jails.ts` has no `toggleIgnoreSelf` function, and `frontend/src/api/endpoints.ts` has no `jailIgnoreSelf` entry.
2. **No hook action** — `UseJailDetailResult` in `useJails.ts` does not expose a `toggleIgnoreSelf` helper.
3. **Read-only UI** — `IgnoreListSection` in `JailDetailPage.tsx` shows an "ignore self" badge when enabled but has no control to change the setting.
As a result, users must use the fail2ban CLI to manage this flag even though the backend is ready.
### What to do
#### Part A — Add endpoint constant (frontend)
**File:** `frontend/src/api/endpoints.ts`
Inside the Jails block, after `jailIgnoreIp`, add:
```ts
jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`,
```
#### Part B — Add API wrapper function (frontend)
**File:** `frontend/src/api/jails.ts`
After the `delIgnoreIp` function in the "Ignore list" section, add:
```ts
/**
* Enable or disable the `ignoreself` flag for a jail.
*
* When enabled, fail2ban automatically adds the server's own IP addresses to
* the ignore list so the host can never ban itself.
*
* @param name - Jail name.
* @param on - `true` to enable, `false` to disable.
* @returns A {@link JailCommandResponse} confirming the change.
* @throws {ApiError} On non-2xx responses.
*/
export async function toggleIgnoreSelf(
name: string,
on: boolean,
): Promise<JailCommandResponse> {
return post<JailCommandResponse>(ENDPOINTS.jailIgnoreSelf(name), on);
}
```
#### Part C — Expose toggle action from hook (frontend)
**File:** `frontend/src/hooks/useJails.ts`
1. Import `toggleIgnoreSelf` at the top (alongside the other API imports).
2. Add `toggleIgnoreSelf: (on: boolean) => Promise<void>` to the `UseJailDetailResult` interface with a JSDoc comment: `/** Enable or disable the ignoreself option for this jail. */`.
3. Inside `useJailDetail`, add the implementation:
```ts
const toggleIgnoreSelf = async (on: boolean): Promise<void> => {
await toggleIgnoreSelfApi(name, on);
load();
};
```
Alias the import as `toggleIgnoreSelfApi` to avoid shadowing the local function name.
4. Add the function to the returned object.
#### Part D — Add toggle control to UI (frontend)
**File:** `frontend/src/pages/JailDetailPage.tsx`
1. Accept `toggleIgnoreSelf: (on: boolean) => Promise<void>` in the `IgnoreListSectionProps` interface.
2. Pass the function from `useJailDetail` down via the existing destructuring and JSX prop.
3. Inside `IgnoreListSection`, render a `<Switch>` next to (or in place of) the read-only badge:
```tsx
<Switch
label="Ignore self (exclude this server's own IPs)"
checked={ignoreSelf}
onChange={(_e, data): void => {
toggleIgnoreSelf(data.checked).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setOpError(msg);
});
}}
/>
```
Import `Switch` from `"@fluentui/react-components"`. Remove the existing read-only badge (it is replaced by the labelled switch, which is self-explanatory). Keep the existing `opError` state and `<MessageBar>` for error display.
### Tests to add
**File:** `frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx` (new file)
Write tests that render the `IgnoreListSection` component (or the full `JailDetailPage` via a shallow-enough render) and cover:
1. **`test_ignore_self_switch_is_checked_when_ignore_self_true`** — when `ignoreSelf=true`, the switch is checked.
2. **`test_ignore_self_switch_is_unchecked_when_ignore_self_false`** — when `ignoreSelf=false`, the switch is unchecked.
3. **`test_toggling_switch_calls_toggle_ignore_self`** — clicking the switch calls `toggleIgnoreSelf` with `false` (when it was `true`).
4. **`test_toggle_error_shows_message_bar`** — when `toggleIgnoreSelf` rejects, the error message bar is rendered.
### Verification
1. Run frontend type check and lint:
```bash
cd frontend && npx tsc --noEmit && npx eslint src/api/jails.ts src/api/endpoints.ts src/hooks/useJails.ts src/pages/JailDetailPage.tsx
```
Zero errors and zero warnings.
2. Run frontend tests:
```bash
cd frontend && npx vitest run src/pages/__tests__/JailDetailIgnoreSelf
```
All 4 new tests pass.
3. **Manual test with running server:**
- Go to `/jails`, click a running jail.
- On the Jail Detail page, scroll to "Ignore List (IP Whitelist)".
- Toggle the "Ignore self" switch on and off — the switch should reflect the live state and the change should survive a page refresh.

View File

@@ -873,6 +873,16 @@ class JailActivationResponse(BaseModel):
default_factory=list,
description="Non-fatal warnings from the pre-activation validation step.",
)
recovered: bool | None = Field(
default=None,
description=(
"Set when activation failed after writing the config file. "
"``True`` means the system automatically rolled back the change and "
"restarted fail2ban. ``False`` means the rollback itself also "
"failed and manual intervention is required. ``None`` when "
"activation succeeded or failed before the file was written."
),
)
# ---------------------------------------------------------------------------

View File

@@ -887,6 +887,50 @@ def _write_local_override_sync(
)
def _restore_local_file_sync(local_path: Path, original_content: bytes | None) -> None:
"""Restore a ``.local`` file to its pre-activation state.
If *original_content* is ``None``, the file is deleted (it did not exist
before the activation). Otherwise the original bytes are written back
atomically via a temp-file rename.
Args:
local_path: Absolute path to the ``.local`` file to restore.
original_content: Original raw bytes to write back, or ``None`` to
delete the file.
Raises:
ConfigWriteError: If the write or delete operation fails.
"""
if original_content is None:
try:
local_path.unlink(missing_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path} during rollback: {exc}"
) from exc
return
tmp_name: str | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=local_path.parent,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(original_content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
if tmp_name is not None:
os.unlink(tmp_name)
raise ConfigWriteError(
f"Failed to restore {local_path} during rollback: {exc}"
) from exc
def _validate_regex_patterns(patterns: list[str]) -> None:
"""Validate each pattern in *patterns* using Python's ``re`` module.
@@ -1163,6 +1207,16 @@ async def activate_jail(
"logpath": req.logpath,
}
# ---------------------------------------------------------------------- #
# Backup the existing .local file (if any) before overwriting it so that #
# we can restore it if activation fails. #
# ---------------------------------------------------------------------- #
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
original_content: bytes | None = await loop.run_in_executor(
None,
lambda: local_path.read_bytes() if local_path.exists() else None,
)
await loop.run_in_executor(
None,
_write_local_override_sync,
@@ -1172,10 +1226,28 @@ async def activate_jail(
overrides,
)
# ---------------------------------------------------------------------- #
# Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- #
try:
await jail_service.reload_all(socket_path, include_jails=[name])
except Exception as exc: # noqa: BLE001
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} activation failed during reload and the "
"configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
# ---------------------------------------------------------------------- #
# Post-reload health probe with retries #
@@ -1192,16 +1264,21 @@ async def activate_jail(
log.warning(
"fail2ban_down_after_activate",
jail=name,
message="fail2ban socket unreachable after reload — daemon may have crashed.",
message="fail2ban socket unreachable after reload — initiating rollback.",
)
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} was written to config but fail2ban stopped "
"responding after reload. The jail configuration may be invalid."
f"Jail {name!r} activation failed: fail2ban stopped responding "
"after reload. The configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
@@ -1212,16 +1289,21 @@ async def activate_jail(
log.warning(
"jail_activation_unverified",
jail=name,
message="Jail did not appear in running jails after reload.",
message="Jail did not appear in running jails — initiating rollback.",
)
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} was written to config but did not start after "
"reload — check the jail configuration (filters, log paths, regex)."
"reload. The configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
@@ -1235,6 +1317,70 @@ async def activate_jail(
)
async def _rollback_activation_async(
config_dir: str,
name: str,
socket_path: str,
original_content: bytes | None,
) -> bool:
"""Restore the pre-activation ``.local`` file and reload fail2ban.
Called internally by :func:`activate_jail` when the activation fails after
the config file was already written. Tries to:
1. Restore the original file content (or delete the file if it was newly
created by the activation attempt).
2. Reload fail2ban so the daemon runs with the restored configuration.
3. Probe fail2ban to confirm it came back up.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Name of the jail whose ``.local`` file should be restored.
socket_path: Path to the fail2ban Unix domain socket.
original_content: Raw bytes of the original ``.local`` file, or
``None`` if the file did not exist before the activation.
Returns:
``True`` if fail2ban is responsive again after the rollback, ``False``
if recovery also failed.
"""
loop = asyncio.get_event_loop()
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
# Step 1 — restore original file (or delete it).
try:
await loop.run_in_executor(
None, _restore_local_file_sync, local_path, original_content
)
log.info("jail_activation_rollback_file_restored", jail=name)
except ConfigWriteError as exc:
log.error(
"jail_activation_rollback_restore_failed", jail=name, error=str(exc)
)
return False
# Step 2 — reload fail2ban with the restored config.
try:
await jail_service.reload_all(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001
log.warning(
"jail_activation_rollback_reload_failed", jail=name, error=str(exc)
)
return False
# Step 3 — wait for fail2ban to come back.
for attempt in range(_POST_RELOAD_MAX_ATTEMPTS):
if attempt > 0:
await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL)
if await _probe_fail2ban_running(socket_path):
log.info("jail_activation_rollback_recovered", jail=name)
return True
log.warning("jail_activation_rollback_still_down", jail=name)
return False
async def deactivate_jail(
config_dir: str,
socket_path: str,

View File

@@ -502,7 +502,8 @@ class TestActivateJail:
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(side_effect=[set(), set()]),
# First call: pre-activation (not active); second: post-reload (started).
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
@@ -2947,3 +2948,166 @@ class TestActivateJailBlocking:
assert result.active is True
mock_js.reload_all.assert_awaited_once()
# ---------------------------------------------------------------------------
# activate_jail — rollback on failure (Task 2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestActivateJailRollback:
"""Rollback logic in activate_jail restores the .local file and recovers."""
async def test_activate_jail_rollback_on_reload_failure(
self, tmp_path: Path
) -> None:
"""Rollback when reload_all raises on the activation reload.
Expects:
- The .local file is restored to its original content.
- The response indicates recovered=True.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
original_local = "[apache-auth]\nenabled = false\n"
local_path = tmp_path / "jail.d" / "apache-auth.local"
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(original_local)
req = ActivateJailRequest()
reload_call_count = 0
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
nonlocal reload_call_count
reload_call_count += 1
if reload_call_count == 1:
raise RuntimeError("fail2ban crashed")
# Recovery reload succeeds.
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is True
assert local_path.read_text() == original_local
async def test_activate_jail_rollback_on_health_check_failure(
self, tmp_path: Path
) -> None:
"""Rollback when fail2ban is unreachable after the activation reload.
Expects:
- The .local file is restored to its original content.
- The response indicates recovered=True.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
original_local = "[apache-auth]\nenabled = false\n"
local_path = tmp_path / "jail.d" / "apache-auth.local"
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(original_local)
req = ActivateJailRequest()
probe_call_count = 0
async def probe_side_effect(socket_path: str) -> bool:
nonlocal probe_call_count
probe_call_count += 1
# First _POST_RELOAD_MAX_ATTEMPTS probes (health-check after
# activation) all fail; subsequent probes (recovery) succeed.
from app.services.config_file_service import _POST_RELOAD_MAX_ATTEMPTS
return probe_call_count > _POST_RELOAD_MAX_ATTEMPTS
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(side_effect=probe_side_effect),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
mock_js.reload_all = AsyncMock()
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is True
assert local_path.read_text() == original_local
async def test_activate_jail_rollback_failure(self, tmp_path: Path) -> None:
"""recovered=False when both the activation and recovery reloads fail.
Expects:
- The response indicates recovered=False.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
original_local = "[apache-auth]\nenabled = false\n"
local_path = tmp_path / "jail.d" / "apache-auth.local"
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(original_local)
req = ActivateJailRequest()
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
# Both the activation reload and the recovery reload fail.
mock_js.reload_all = AsyncMock(
side_effect=RuntimeError("fail2ban unavailable")
)
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is False

View File

@@ -12,6 +12,7 @@ import {
Tooltip,
} from "recharts";
import type { PieLabelRenderProps } from "recharts";
import type { LegendPayload } from "recharts/types/component/DefaultLegendContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles, Text } from "@fluentui/react-components";
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
@@ -153,12 +154,19 @@ export function TopCountriesPieChart({
);
}
/** Format legend entries as "Country Name (xx%)" */
const legendFormatter = (value: string): string => {
/** Format legend entries as "Country Name (xx%)" and colour them to match their slice. */
const legendFormatter = (
value: string,
entry: LegendPayload,
): React.ReactNode => {
const slice = slices.find((s) => s.name === value);
if (slice == null || total === 0) return value;
const pct = ((slice.value / total) * 100).toFixed(1);
return `${value} (${pct}%)`;
return (
<span style={{ color: entry.color }}>
{value} ({pct}%)
</span>
);
};
return (

View File

@@ -26,6 +26,7 @@ import {
Input,
MessageBar,
MessageBarBody,
MessageBarTitle,
Spinner,
Text,
tokens,
@@ -85,6 +86,7 @@ export function ActivateJailDialog({
const [logpath, setLogpath] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recoveryStatus, setRecoveryStatus] = useState<"recovered" | "unrecovered" | null>(null);
// Pre-activation validation state
const [validating, setValidating] = useState(false);
@@ -98,6 +100,7 @@ export function ActivateJailDialog({
setPort("");
setLogpath("");
setError(null);
setRecoveryStatus(null);
setValidationIssues([]);
setValidationWarnings([]);
};
@@ -153,10 +156,17 @@ export function ActivateJailDialog({
activateJail(jail.name, overrides)
.then((result) => {
if (!result.active) {
// Backend rejected the activation (e.g. missing logpath or filter).
// Show the server's message and keep the dialog open so the user
// can read the explanation without the dialog disappearing.
if (result.recovered === true) {
// Activation failed but the system rolled back automatically.
setRecoveryStatus("recovered");
} else if (result.recovered === false) {
// Activation failed and rollback also failed.
setRecoveryStatus("unrecovered");
} else {
// Backend rejected before writing (e.g. missing logpath or filter).
// Show the server's message and keep the dialog open.
setError(result.message);
}
return;
}
if (result.validation_warnings.length > 0) {
@@ -323,6 +333,31 @@ export function ActivateJailDialog({
onChange={(_e, d) => { setLogpath(d.value); }}
/>
</Field>
{recoveryStatus === "recovered" && (
<MessageBar
intent="warning"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed System Recovered</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed. The server
has been automatically recovered.
</MessageBarBody>
</MessageBar>
)}
{recoveryStatus === "unrecovered" && (
<MessageBar
intent="error"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed Manual Intervention Required</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed and
automatic recovery was unsuccessful. Manual intervention is
required.
</MessageBarBody>
</MessageBar>
)}
{error && (
<MessageBar
intent="error"

View File

@@ -185,9 +185,9 @@ const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", to: "/", icon: <GridRegular />, end: true },
{ label: "World Map", to: "/map", icon: <MapRegular /> },
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
{ label: "History", to: "/history", icon: <HistoryRegular /> },
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
];
// ---------------------------------------------------------------------------

View File

@@ -553,6 +553,13 @@ export interface JailActivationResponse {
fail2ban_running: boolean;
/** Non-fatal pre-activation validation warnings (e.g. missing log path). */
validation_warnings: string[];
/**
* Set when activation failed after the config file was already written.
* `true` = the system rolled back and recovered automatically.
* `false` = rollback also failed — manual intervention required.
* `undefined` = activation succeeded or failed before the file was written.
*/
recovered?: boolean;
}
// ---------------------------------------------------------------------------