Merge Global tab into Server tab and remove Global tab

Global tab provided the same four editable fields as Server tab:
log_level, log_target, db_purge_age, db_max_matches. Server tab already
has these fields plus additional read-only info (db_path, syslog_socket)
and a Flush Logs button.

- Add hint text to DB Purge Age and DB Max Matches fields in ServerTab
- Remove GlobalTab component import from ConfigPage
- Remove 'global' from TabValue type
- Remove Global tab element from TabList
- Remove conditional render for GlobalTab
- Remove GlobalTab from barrel export (index.ts)
- Delete GlobalTab.tsx file
- Update ConfigPage test to remove Global tab test case

All 123 frontend tests pass.
This commit is contained in:
2026-03-14 21:52:44 +01:00
parent 2e1a4b3b2b
commit 037c18eb00
6 changed files with 150 additions and 236 deletions

View File

@@ -4,97 +4,160 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Task 1Move "Configuration" to the Last Position in the Sidebar ✅ DONE
## Task 3Fix Transparent Pie Chart Slices and Match Legend Label Colors to Slice Colors
**Summary:** Moved the `Configuration` entry in `NAV_ITEMS` to the last position in `frontend/src/layouts/MainLayout.tsx`.
### Root Cause
**File:** `frontend/src/layouts/MainLayout.tsx`
The pie chart slices appear transparent because `resolveFluentToken` in `frontend/src/utils/chartTheme.ts` fails to resolve Fluent UI CSS custom properties. It calls `getComputedStyle(document.documentElement)` — but `document.documentElement` is the `<html>` element, and Fluent UI v9's `FluentProvider` injects its CSS custom properties on its own wrapper `<div class="fui-FluentProvider ...">`, **not** on `<html>` or `:root`. Therefore:
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:
1. `getComputedStyle(document.documentElement).getPropertyValue('--colorPaletteBlueBorderActive')` returns `""` (empty string).
2. `resolveFluentToken` falls back to returning the raw token string, e.g. `"var(--colorPaletteBlueBorderActive)"`.
3. Recharts internally parses colour values for SVG rendering and animation interpolation. It cannot parse a `var(...)` reference so the SVG `fill` attribute ends up transparent/unset.
1. Dashboard
2. World Map
3. Jails
4. History
5. Blocklists
6. Configuration
This affects **all four chart components**`TopCountriesPieChart`, `TopCountriesBarChart`, `BanTrendChart`, and `JailDistributionChart` — since they all call `resolveFluentToken`. However the pie chart is the most visually obvious case because its slices have no fallback colour.
Only the position in the array changes. Do not modify the label, path, or icon of any item.
### Fix
**File:** `frontend/src/utils/chartTheme.ts``resolveFluentToken` function (around line 30)
Change the element passed to `getComputedStyle` from `document.documentElement` to the FluentProvider's wrapper element. The FluentProvider wrapper always has the CSS class `fui-FluentProvider` (this is a stable class name defined in `@fluentui/react-provider`). Query for it with `document.querySelector`:
```typescript
export function resolveFluentToken(tokenValue: string): string {
const match = /var\((--[^,)]+)/.exec(tokenValue);
if (match == null || match[1] == null) return tokenValue;
// FluentProvider injects CSS custom properties on its own wrapper <div>,
// not on :root. Query that element so we resolve actual colour values.
const el =
document.querySelector(".fui-FluentProvider") ?? document.documentElement;
const resolved = getComputedStyle(el)
.getPropertyValue(match[1])
.trim();
return resolved !== "" ? resolved : tokenValue;
}
```
This is the **only change needed** in this file. Do not modify `CHART_PALETTE` or any other export.
### Verification
After applying the fix above:
- Open the Dashboard page in the browser.
- The pie chart slices must be filled with the palette colours (blue, red, green, gold, purple).
- The bar chart, area chart, and jail distribution chart should also display their colours correctly.
- The legend labels next to the pie chart must have the same font colour as their corresponding slice (this was already implemented by the previous agent in `TopCountriesPieChart.tsx` via the `legendFormatter` that wraps text in a `<span style={{ color: entry.color }}>`). Since `entry.color` comes from the Recharts payload which reads the Cell `fill`, once the fill values are real hex strings the legend colours will also be correct.
### What NOT to change
- Do **not** modify `TopCountriesPieChart.tsx` — the `legendFormatter` changes already applied there are correct and will work once colours resolve properly.
- Do **not** modify `CHART_PALETTE` or switch from Fluent tokens to hard-coded hex values — the token-based approach is correct; only the resolution target element was wrong.
- Do **not** add refs or hooks to individual chart components — the single-line fix in `resolveFluentToken` is sufficient.
---
## Task 2Auto-Recovery When Jail Activation Fails ✅ DONE
## Task 4Merge Global Tab into Server Tab (Remove Duplicates)
**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.
**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.
### Backend Changes
**File:** `backend/app/services/config_file_service.py``activate_jail()` method (around line 1086)
Wrap the reload-and-verify sequence in error handling that performs a rollback on failure:
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.
**File:** `backend/app/routers/config.py``activate_jail` endpoint (around line 584)
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 — Match Pie Chart Slice Colors to Country Label Font Colors ✅ 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`.
**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**.
The Global tab (`frontend/src/components/config/GlobalTab.tsx`) and the Server tab (`frontend/src/components/config/ServerTab.tsx`) both expose the same four editable fields: **Log Level**, **Log Target**, **DB Purge Age**, and **DB Max Matches**. The server tab additionally shows read-only **DB Path** and **Syslog Socket** fields, plus a **Flush Logs** button. Having both tabs is confusing and can cause conflicting writes.
### Changes
**File:** `frontend/src/components/TopCountriesPieChart.tsx`
1. **Remove the Global tab entirely.**
- In `frontend/src/pages/ConfigPage.tsx`:
- Remove `"global"` from the `TabValue` union type.
- Remove the `<Tab value="global">Global</Tab>` element from the `<TabList>`.
- Remove the `{tab === "global" && <GlobalTab />}` conditional render.
- Remove the `GlobalTab` import.
- In the barrel export file (`frontend/src/components/config/index.ts`): remove the `GlobalTab` re-export.
- Delete the file `frontend/src/components/config/GlobalTab.tsx`.
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:
2. **Ensure the Server tab retains all functionality.** It already has all four editable fields plus extra read-only info and Flush Logs. No changes needed in `ServerTab.tsx` — it already covers everything Global had. Verify the field hints from Global ("Ban records older than this…" and "Maximum number of log-line matches…") are present on the Server tab's DB Purge Age and DB Max Matches fields. If they are missing, copy them over from `GlobalTab.tsx` before deleting it.
```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>
);
}}
```
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.
Do **not** change the pie slice colors themselves — only the country label font color must match the slice it corresponds to.
3. **Backend**: No backend changes needed. The `PUT /api/config/global` endpoint stays; the Server tab already uses its own update mechanism via `useServerSettings`.
---
## Task 5 — Merge Map Tab into Server Tab
The Map tab (`frontend/src/components/config/MapTab.tsx`) configures map color thresholds (Low / Medium / High). Move this section into the Server tab so all server-side and display configuration is in one place.
### Changes
1. **Add a "Map Color Thresholds" section to `ServerTab.tsx`.**
- Below the existing server settings card and Flush Logs button, add a new `sectionCard` block containing the map threshold form fields (Low, Medium, High) with the same validation logic currently in `MapTab.tsx` (high > medium > low, all positive integers).
- Use `useAutoSave` to save thresholds, calling `updateMapColorThresholds` from `frontend/src/api/config.ts`. Fetch initial values with `fetchMapColorThresholds`.
- Add a section heading: "Map Color Thresholds" and the description text explaining how thresholds drive country fill colors on the World Map page.
2. **Remove the Map tab.**
- In `frontend/src/pages/ConfigPage.tsx`: remove `"map"` from `TabValue`, remove the `<Tab value="map">Map</Tab>` element, remove the conditional render, remove the `MapTab` import.
- In the barrel export: remove the `MapTab` re-export.
- Delete `frontend/src/components/config/MapTab.tsx`.
3. **Backend**: No changes needed. The `GET/PUT /api/config/map-color-thresholds` endpoints stay as-is.
---
## Task 6 — Merge Log Tab into Server Tab
The Log tab (`frontend/src/components/config/LogTab.tsx`) shows a Service Health panel and a log viewer. Move both into the Server tab to consolidate all server-related views.
### Changes
1. **Add a "Service Health" section to `ServerTab.tsx`.**
- Below the map thresholds section (from Task 5), add the service health grid showing: online status badge, fail2ban version, active jail count, total bans, total failures, log level (read-only), log target (read-only). Fetch this data from `fetchServiceStatus` in `frontend/src/api/config.ts`.
2. **Add the "Log Viewer" section to `ServerTab.tsx`.**
- Below the health panel, add the log viewer with all its existing controls: line count selector, filter input, refresh button, auto-refresh toggle, and the colour-coded log display. Migrate all the log-related state, refs, and helper functions from `LogTab.tsx`.
- Keep the line-severity colour coding (ERROR=red, WARNING=yellow, DEBUG=gray).
3. **Remove the Log tab.**
- In `frontend/src/pages/ConfigPage.tsx`: remove `"log"` from `TabValue`, remove the `<Tab value="log">Log</Tab>` element, remove the conditional render, remove the `LogTab` import.
- In the barrel export: remove the `LogTab` re-export.
- Delete `frontend/src/components/config/LogTab.tsx`.
4. **Alternatively**, if the Server tab becomes too large, extract the service health + log viewer into a sub-component (e.g. `ServerHealthSection.tsx`) and render it inside `ServerTab.tsx`. This keeps the code manageable but still presents a single tab to the user.
5. **Backend**: No changes needed.
---
## Task 7 — Add Reload / Restart Button to Server Section
Add a button (or two buttons) in the Server tab that allows the user to reload or restart the fail2ban service on demand.
### Backend
A reload endpoint already exists: `POST /api/config/reload` (see `backend/app/routers/config.py`, around line 342). It calls `jail_service.reload_all()` which stops and restarts all jails with the current config. No new backend endpoint is needed for reload.
For a **restart** (full daemon restart, not just reload), check whether the backend already supports it. If not, add a new endpoint:
- `POST /api/config/restart` — calls `jail_service.restart()` (or equivalent `fail2ban-client restart`). This should fully stop and restart the fail2ban daemon. Return 204 on success, 502 if fail2ban is unreachable.
### Frontend
**File:** `frontend/src/components/config/ServerTab.tsx`
A frontend API function `reloadConfig` already exists in `frontend/src/api/config.ts` (around line 94).
1. In the Server tab's button row (next to the existing "Flush Logs" button), add two new buttons:
- **"Reload fail2ban"** — calls `reloadConfig()`. Show a loading spinner while in progress, and a success/error `MessageBar` when done. Use a descriptive icon such as `ArrowSync24Regular` or `ArrowClockwise24Regular`.
- **"Restart fail2ban"** — calls the restart API (if added above, or the same reload endpoint if a full restart is not available). Use a warning-style appearance or a confirmation dialog before executing, since a restart briefly takes fail2ban offline.
2. Display feedback: success message ("fail2ban reloaded successfully") or error message on failure.
3. Optionally, after a successful reload/restart, re-fetch the service health data so the health panel updates immediately.
### Frontend API
If a restart endpoint was added to the backend, add a corresponding function in `frontend/src/api/config.ts`:
```typescript
export async function restartFail2Ban(): Promise<void> {
await post<undefined>(ENDPOINTS.configRestart, undefined);
}
```
And add `configRestart: "/api/config/restart"` to the `ENDPOINTS` object.
---

View File

@@ -1,142 +0,0 @@
/**
* GlobalTab — global fail2ban settings editor.
*
* Provides form fields for log level, log target, database purge age,
* and database max matches.
*/
import { useEffect, useMemo, useState } from "react";
import {
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
} from "@fluentui/react-components";
import type { GlobalConfigUpdate } from "../../types/config";
import { useGlobalConfig } from "../../hooks/useConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
/** Available fail2ban log levels in descending severity order. */
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
/**
* Tab component for editing global fail2ban configuration.
*
* @returns JSX element.
*/
export function GlobalTab(): React.JSX.Element {
const styles = useConfigStyles();
const { config, loading, error, updateConfig } = useGlobalConfig();
const [logLevel, setLogLevel] = useState("");
const [logTarget, setLogTarget] = useState("");
const [dbPurgeAge, setDbPurgeAge] = useState("");
const [dbMaxMatches, setDbMaxMatches] = useState("");
// Sync local state when config loads for the first time.
useEffect(() => {
if (config && logLevel === "") {
setLogLevel(config.log_level);
setLogTarget(config.log_target);
setDbPurgeAge(String(config.db_purge_age));
setDbMaxMatches(String(config.db_max_matches));
}
// Only run on first config load.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const effectiveLogLevel = logLevel || config?.log_level || "";
const effectiveLogTarget = logTarget || config?.log_target || "";
const effectiveDbPurgeAge =
dbPurgeAge || (config ? String(config.db_purge_age) : "");
const effectiveDbMaxMatches =
dbMaxMatches || (config ? String(config.db_max_matches) : "");
const updatePayload = useMemo<GlobalConfigUpdate>(() => {
const update: GlobalConfigUpdate = {};
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
if (effectiveDbPurgeAge)
update.db_purge_age = Number(effectiveDbPurgeAge);
if (effectiveDbMaxMatches)
update.db_max_matches = Number(effectiveDbMaxMatches);
return update;
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(updatePayload, updateConfig);
if (loading) return <Spinner label="Loading global config…" />;
if (error)
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
return (
<div>
<div className={styles.sectionCard}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
<div className={styles.fieldRow}>
<Field label="Log Level">
<Select
value={effectiveLogLevel}
onChange={(_e, d) => {
setLogLevel(d.value);
}}
>
{LOG_LEVELS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
</Field>
<Field label="Log Target">
<Input
value={effectiveLogTarget}
placeholder="STDOUT / /var/log/fail2ban.log"
onChange={(_e, d) => {
setLogTarget(d.value);
}}
/>
</Field>
</div>
<div className={styles.fieldRow}>
<Field
label="DB Purge Age (s)"
hint="Ban records older than this are removed from the fail2ban database."
>
<Input
type="number"
value={effectiveDbPurgeAge}
onChange={(_e, d) => {
setDbPurgeAge(d.value);
}}
/>
</Field>
<Field
label="DB Max Matches"
hint="Maximum number of log-line matches stored per ban record."
>
<Input
type="number"
value={effectiveDbMaxMatches}
onChange={(_e, d) => {
setDbMaxMatches(d.value);
}}
/>
</Field>
</div>
</div>
</div>
);
}

View File

@@ -154,7 +154,10 @@ export function ServerTab(): React.JSX.Element {
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="DB Purge Age (s)">
<Field
label="DB Purge Age (s)"
hint="Ban records older than this are removed from the fail2ban database."
>
<Input
type="number"
value={effectiveDbPurgeAge}
@@ -163,7 +166,10 @@ export function ServerTab(): React.JSX.Element {
}}
/>
</Field>
<Field label="DB Max Matches">
<Field
label="DB Max Matches"
hint="Maximum number of log-line matches stored per ban record."
>
<Input
type="number"
value={effectiveDbMaxMatches}

View File

@@ -30,7 +30,6 @@ export { ExportTab } from "./ExportTab";
export { FilterForm } from "./FilterForm";
export type { FilterFormProps } from "./FilterForm";
export { FiltersTab } from "./FiltersTab";
export { GlobalTab } from "./GlobalTab";
export { JailFilesTab } from "./JailFilesTab";
export { JailFileForm } from "./JailFileForm";
export { JailsTab } from "./JailsTab";

View File

@@ -8,8 +8,7 @@
* Jails — per-jail config accordion with inline editing
* Filters — structured filter.d form editor
* Actions — structured action.d form editor
* Globalglobal fail2ban settings (log level, DB config)
* Server — server-level settings + flush logs
* Serverserver-level settings, logging, database config + flush logs
* Map — map color threshold configuration
* Regex Tester — live pattern tester
* Export — raw file editors for jail, filter, and action files
@@ -20,7 +19,6 @@ import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-componen
import {
ActionsTab,
FiltersTab,
GlobalTab,
JailsTab,
LogTab,
MapTab,
@@ -58,7 +56,6 @@ type TabValue =
| "jails"
| "filters"
| "actions"
| "global"
| "server"
| "map"
| "regex"
@@ -89,7 +86,6 @@ export function ConfigPage(): React.JSX.Element {
<Tab value="jails">Jails</Tab>
<Tab value="filters">Filters</Tab>
<Tab value="actions">Actions</Tab>
<Tab value="global">Global</Tab>
<Tab value="server">Server</Tab>
<Tab value="map">Map</Tab>
<Tab value="regex">Regex Tester</Tab>
@@ -100,7 +96,6 @@ export function ConfigPage(): React.JSX.Element {
{tab === "jails" && <JailsTab />}
{tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />}
{tab === "global" && <GlobalTab />}
{tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />}
{tab === "regex" && <RegexTesterTab />}

View File

@@ -9,7 +9,6 @@ vi.mock("../../components/config", () => ({
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
GlobalTab: () => <div data-testid="global-tab">GlobalTab</div>,
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
MapTab: () => <div data-testid="map-tab">MapTab</div>,
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
@@ -45,12 +44,6 @@ describe("ConfigPage", () => {
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
});
it("switches to Global tab when Global tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /global/i }));
expect(screen.getByTestId("global-tab")).toBeInTheDocument();
});
it("switches to Server tab when Server tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /server/i }));