`. Changing `key` forces React to unmount the old subtree and mount a brand-new one. `JailConfigDetail` alone has 20+ `useState` fields (banTime, findTime, maxRetry, failRegex, ignoreRegex, logPaths, datePattern, dnsMode, backend, logEncoding, prefRegex, plus all ban-time escalation fields). All of these are reset when the user switches tabs and switches back.
**Goal**
Remove the `key={tab}` from the wrapping `
` and instead conditionally render each tab panel using `display: none` / `display: block` (or Fluent UI `TabPanel` visibility) so components stay mounted throughout the page lifetime. Alternatively, lift the shared state up to `ConfigPage` or a context so it survives remounts. The simplest fix is removing the key and using CSS visibility.
**Possible traps and issues**
- Hidden tabs still run their hooks (data fetches, effects). This is acceptable because the tabs are already mounted when the page loads; the behaviour becomes consistent rather than worse.
- If tab-specific effects must fire on "tab activation" they need to be converted from key-based remount triggers to explicit activation flags.
- Auto-save in `JailConfigDetail` uses `useAutoSave` which has its own pending-save debounce. Keeping the component mounted means a pending save from one session survives correctly across tab switches, which is the desired behaviour.
**Docs changes needed**
Update `Docs/Web-Development.md` to note that tab panels must not use `key` for tab identity.
**Why this is needed**
Users editing a jail config (filling in ban time, regex patterns, log paths) lose all unsaved changes if they accidentally click another tab and return. The auto-save timer is also cancelled, silently dropping work.
---
### TASK-BUG-03 — MapPage Pagination Resets on Every Data Refresh
**Where found**
`frontend/src/pages/MapPage.tsx` lines ~350–352:
```ts
useEffect(() => {
setPage(1);
}, [range, originFilter, selectedCountry, bans, pageSize]);
```
`bans` is the array returned by `useMapData`. Every time `useMapData` completes a fetch it produces a new array reference. This causes `setPage(1)` to fire after every single background refresh, not just when the user changes a filter.
**Goal**
Remove `bans` from the dependency array. Page should only reset to 1 when the user changes a *filter* (range, originFilter, selectedCountry, pageSize), not when the underlying data refreshes.
**Possible traps and issues**
- After removing `bans`, if `bans.length` shrinks below the current page offset (e.g. the user is on page 3, data refreshes with fewer results), the pagination will show an empty page. Add a separate effect that clamps `page` to `totalPages` when `totalPages` changes: `if (page > totalPages) setPage(totalPages)`.
- `visibleBans` is already derived from `bans` via `useMemo`, so the table stays correct without `bans` in the reset effect.
**Docs changes needed**
None required.
**Why this is needed**
Users browsing the per-country ban table on MapPage are returned to page 1 every time the background auto-refresh fires (typically every 30 seconds), which makes the table unusable for pagination beyond the first page.
---
### TASK-BUG-04 — `autoSavePayload` Silently Drops Intentional Zero Values
**Where found**
`frontend/src/components/config/JailsTab.tsx` lines 213–215:
```ts
ban_time: Number(banTime) || jail.ban_time,
find_time: Number(findTime) || jail.find_time,
max_retry: Number(maxRetry) || jail.max_retry,
```
The `||` operator treats `0` as falsy, so when a user sets `ban_time` to `0` (which in fail2ban means "ban permanently") the payload silently falls back to the server's current value. The user's intent is discarded without any error.
**Goal**
Replace the `||` fallback with an explicit `NaN` guard:
```ts
ban_time: Number.isNaN(Number(banTime)) ? jail.ban_time : Number(banTime),
find_time: Number.isNaN(Number(findTime)) ? jail.find_time : Number(findTime),
max_retry: Number.isNaN(Number(maxRetry)) ? jail.max_retry : Number(maxRetry),
```
This preserves `0` and other valid numeric values while still falling back when the field contains non-numeric text.
**Possible traps and issues**
- An empty string converts to `0` via `Number("")`, which would then be sent to the API. If empty input is invalid, add a separate guard: only fall back if the trimmed string is empty or `NaN`.
- `max_retry` of `0` is meaningless in fail2ban (would never ban). Consider adding UI validation that shows a warning for `max_retry < 1` rather than silently correcting it.
**Docs changes needed**
None required.
**Why this is needed**
`ban_time = 0` in fail2ban sets a permanent ban — a common use case for admin hardening. The current code makes it impossible to save this value through the UI, with no error shown to the user.
---
### TASK-BUG-05 — `InactiveJailDetail.handleValidate` Swallows Network Failures
**Where found**
`frontend/src/components/config/JailsTab.tsx` inside `InactiveJailDetail`, the `handleValidate` callback:
```ts
onValidate()
.then((result) => { setValidationResult(result); })
.catch(() => { /* validation call failed — ignore */ })
.finally(() => { setValidating(false); });
```
If the API call fails (network error, 500, timeout), the spinner stops, `validationResult` remains `null`, and there is zero user feedback. The user cannot distinguish a clean "no issues" result from a silent failure.
**Goal**
Add error state to `InactiveJailDetail` and render a `MessageBar intent="error"` when validation fails:
```ts
.catch((err: unknown) => {
setValidationError(err instanceof Error ? err.message : "Validation request failed.");
})
```
Clear `validationError` when the user clicks Validate again.
**Possible traps and issues**
- The existing `validationResult` display logic handles the empty-issues case with a success banner. Ensure the new error state renders instead of the success banner when set.
- `handleFetchError` should be used (or replicated) so that auth errors don't show a generic error banner — they should trigger the session-expiry flow instead.
**Docs changes needed**
None required.
**Why this is needed**
Silent failure is worse than a visible error. A user who validates a jail config and sees nothing has no idea whether validation passed or the server is unreachable.
---
### TASK-BUG-06 — `JailConfigDetail` Form State Never Re-syncs After Background Refresh
**Where found**
`frontend/src/components/config/JailsTab.tsx`. `JailConfigDetail` initialises all 20+ form fields from `jail` prop in `useState` calls (lines 126–161). The component uses `key={selectedActiveJail.name}`, which forces remount only when the *selected jail changes*, not when the data for the already-selected jail is refreshed by the parent. If `useJailConfigs` does a background refresh and delivers updated server data for the currently-selected jail, the form continues displaying the stale locally-edited values.
**Goal**
Add a `useEffect` that resets form fields when the incoming `jail` prop changes identity (i.e. when a server refresh delivers a new object for the same jail name). The effect must only run when the user is not mid-edit. The cleanest approach is to track a `lastSavedJail` ref and compare it to the incoming `jail`; if the auto-save has no pending changes and `jail` has changed, reset the fields.
Alternatively, expose a `resetToServer` button that lets the user explicitly pull the latest server state without relying on automatic detection.
**Possible traps and issues**
- Automatically resetting a form a user is actively editing is hostile. The reset must only happen when `autoSave` reports no pending changes and no dirty state.
- Comparing the full `jail` object on every render is expensive; use a ref to track the last-applied server version by comparing a stable property like `jail.name + JSON.stringify(jail)` (hashed or shallow-compared field by field).
- This issue is partially mitigated by `key={selectedActiveJail.name}` forcing remount on jail selection change.
**Docs changes needed**
None required.
**Why this is needed**
If fail2ban reloads externally (e.g. another admin makes a change), the GUI background-refreshes the config but the currently-open form silently shows stale data. A save action would overwrite the external change.
---
### TASK-BUG-07 — `useJails()` Called Twice on `JailsPage` (Double HTTP Request)
**Where found**
`frontend/src/pages/JailsPage.tsx` line 11: `const { jails } = useJails();` — used only to extract `jailNames` for `useIpLookup`. `frontend/src/pages/jails/JailOverviewSection.tsx` line 55: `const { jails, ... } = useJails();` — the full feature hook. Both components are rendered simultaneously on `JailsPage`, causing two parallel `GET /api/jails` requests on every page load.
**Goal**
Remove the `useJails()` call from `JailsPage`. Pass `jailNames` to `JailsPage`'s children as a prop from `JailOverviewSection`, or lift `useJails()` to `JailsPage` and thread `jails` down as a prop to `JailOverviewSection`. Since `JailOverviewSection` already owns all the jail operations, the simplest fix is to accept an optional `onJailNamesLoaded` callback or have `JailsPage` access jail names directly from `JailOverviewSection` via a ref or by reading from the single hook call.
**Possible traps and issues**
- `JailsPage` currently passes `jailNames` to a separate `IpLookupSection` or similar. After consolidating to one `useJails()` call the prop-drilling path needs to be updated.
- `JailOverviewSection` is the authoritative consumer of `useJails`; making `JailsPage` the single call site and passing the result down as props is the cleanest structural change.
**Docs changes needed**
None required.
**Why this is needed**
Every visit to the Jails page sends two identical requests to the backend. At scale with many jails this doubles the serialization and deserialization cost for no benefit.
---
### TASK-BUG-08 — `AssignActionDialog` and `AssignFilterDialog` Call `useJails()` When Closed
**Where found**
`frontend/src/components/config/AssignActionDialog.tsx` line 71 and `frontend/src/components/config/AssignFilterDialog.tsx` line 71. Both components call `useJails()` unconditionally at the top of the component body. The parent mounts these dialogs regardless of their `open` prop (so they can animate in), meaning `GET /api/jails` is fired every time the Config page tab containing these dialogs renders, even when the dialogs are never opened.
**Goal**
Gate the `useJails()` call behind the `open` prop. Because React hooks cannot be called conditionally, the fix is to extract the dialog body into a separate inner component that is only rendered when `open` is true:
```tsx
export function AssignActionDialog({ open, ... }) {
return open ?
: null;
}
function AssignActionDialogInner({ ... }) {
const { jails, ... } = useJails();
...
}
```
This way `useJails()` only mounts (and fetches) when the dialog is actually open.
**Possible traps and issues**
- Fluent UI Dialog animations may require the wrapper element to always exist for the open/close animation to work. In that case keep the `