fix(config): gate useJails() calls behind dialog open prop
Refactored AssignActionDialog and AssignFilterDialog to only render dialog content when open=true. This prevents useJails() from being called when dialogs are closed, eliminating unnecessary GET /api/jails requests. Implementation uses inner components (AssignActionDialogInner, AssignFilterDialogInner) that are only mounted when the dialog is open. The Dialog wrapper remains in the outer component to preserve Fluent UI animation behavior. Fixed test setup for AssignFilterDialog to properly call assignFilterToJail from the mocked onAssign callback. Fixes TASK-BUG-08. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,46 +1,3 @@
|
||||
### 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**
|
||||
|
||||
@@ -54,9 +54,8 @@ export interface AssignActionDialogProps {
|
||||
/**
|
||||
* Confirmation dialog for assigning an action to a jail.
|
||||
*
|
||||
* Fetches running jails from the API for the jail selector. The user can
|
||||
* optionally request a fail2ban reload immediately after the assignment is
|
||||
* written.
|
||||
* Wraps AssignActionDialogInner and only renders the inner component when
|
||||
* the dialog is open, preventing unnecessary API calls when closed.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
@@ -68,6 +67,43 @@ export function AssignActionDialog({
|
||||
onAssigned,
|
||||
onAssign,
|
||||
}: AssignActionDialogProps): React.JSX.Element {
|
||||
const handleClose = useCallback((): void => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
|
||||
<DialogSurface>
|
||||
{open && (
|
||||
<AssignActionDialogInner
|
||||
actionName={actionName}
|
||||
onClose={handleClose}
|
||||
onAssigned={onAssigned}
|
||||
onAssign={onAssign}
|
||||
/>
|
||||
)}
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssignActionDialogInnerProps {
|
||||
actionName: string | null;
|
||||
onClose: () => void;
|
||||
onAssigned: () => void;
|
||||
onAssign: (jailName: string, payload: AssignActionRequest, reload: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that contains the dialog body and only renders when open.
|
||||
* This ensures useJails() is only called when the dialog is actually visible.
|
||||
*/
|
||||
function AssignActionDialogInner({
|
||||
actionName,
|
||||
onClose,
|
||||
onAssigned,
|
||||
onAssign,
|
||||
}: AssignActionDialogInnerProps): React.JSX.Element {
|
||||
const { jails, loading: jailsLoading, error: jailsError } = useJails();
|
||||
const [selectedJail, setSelectedJail] = useState("");
|
||||
const [reload, setReload] = useState(false);
|
||||
@@ -77,17 +113,16 @@ export function AssignActionDialog({
|
||||
const activeJails = jails.filter((j) => j.enabled);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setSelectedJail("");
|
||||
setReload(false);
|
||||
}, [open]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (jailsError && open) {
|
||||
if (jailsError) {
|
||||
setError(jailsError);
|
||||
}
|
||||
}, [jailsError, open]);
|
||||
}, [jailsError]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (submitting) return;
|
||||
@@ -119,86 +154,82 @@ export function AssignActionDialog({
|
||||
const canConfirm = selectedJail !== "" && !submitting && !jailsLoading;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Assign Action to Jail</DialogTitle>
|
||||
<DialogContent>
|
||||
{actionName !== null && (
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Assign action{" "}
|
||||
<Text weight="semibold" style={{ fontFamily: "monospace" }}>
|
||||
{actionName}
|
||||
</Text>{" "}
|
||||
to a jail. This appends the action to the jail's{" "}
|
||||
<code>action</code> list in its <code>.local</code> override file.
|
||||
</Text>
|
||||
)}
|
||||
<DialogBody>
|
||||
<DialogTitle>Assign Action to Jail</DialogTitle>
|
||||
<DialogContent>
|
||||
{actionName !== null && (
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Assign action{" "}
|
||||
<Text weight="semibold" style={{ fontFamily: "monospace" }}>
|
||||
{actionName}
|
||||
</Text>{" "}
|
||||
to a jail. This appends the action to the jail's{" "}
|
||||
<code>action</code> list in its <code>.local</code> override file.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error !== null && (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Target jail"
|
||||
required
|
||||
hint="Only currently enabled jails are listed."
|
||||
<Field
|
||||
label="Target jail"
|
||||
required
|
||||
hint="Only currently enabled jails are listed."
|
||||
>
|
||||
{jailsLoading ? (
|
||||
<Spinner size="extra-small" label="Loading jails…" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedJail}
|
||||
onChange={(_e, d) => { setSelectedJail(d.value); }}
|
||||
aria-label="Target jail"
|
||||
>
|
||||
{jailsLoading ? (
|
||||
<Spinner size="extra-small" label="Loading jails…" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedJail}
|
||||
onChange={(_e, d) => { setSelectedJail(d.value); }}
|
||||
aria-label="Target jail"
|
||||
>
|
||||
<option value="" disabled>
|
||||
— select a jail —
|
||||
</option>
|
||||
{activeJails.map((j) => (
|
||||
<option key={j.name} value={j.name}>
|
||||
{j.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<option value="" disabled>
|
||||
— select a jail —
|
||||
</option>
|
||||
{activeJails.map((j) => (
|
||||
<option key={j.name} value={j.name}>
|
||||
{j.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Checkbox
|
||||
label="Reload fail2ban after assigning"
|
||||
checked={reload}
|
||||
onChange={(_e, d) => { setReload(Boolean(d.checked)); }}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
icon={submitting ? <Spinner size="extra-small" /> : undefined}
|
||||
>
|
||||
{submitting ? "Assigning…" : "Assign"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
<Checkbox
|
||||
label="Reload fail2ban after assigning"
|
||||
checked={reload}
|
||||
onChange={(_e, d) => { setReload(Boolean(d.checked)); }}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
icon={submitting ? <Spinner size="extra-small" /> : undefined}
|
||||
>
|
||||
{submitting ? "Assigning…" : "Assign"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,9 +54,8 @@ export interface AssignFilterDialogProps {
|
||||
/**
|
||||
* Confirmation dialog for assigning a filter to a jail.
|
||||
*
|
||||
* Fetches running jails from the API for the jail selector. The user can
|
||||
* optionally request a fail2ban reload immediately after the assignment is
|
||||
* written.
|
||||
* Wraps AssignFilterDialogInner and only renders the inner component when
|
||||
* the dialog is open, preventing unnecessary API calls when closed.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
@@ -68,6 +67,43 @@ export function AssignFilterDialog({
|
||||
onAssigned,
|
||||
onAssign,
|
||||
}: AssignFilterDialogProps): React.JSX.Element {
|
||||
const handleClose = useCallback((): void => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
|
||||
<DialogSurface>
|
||||
{open && (
|
||||
<AssignFilterDialogInner
|
||||
filterName={filterName}
|
||||
onClose={handleClose}
|
||||
onAssigned={onAssigned}
|
||||
onAssign={onAssign}
|
||||
/>
|
||||
)}
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssignFilterDialogInnerProps {
|
||||
filterName: string | null;
|
||||
onClose: () => void;
|
||||
onAssigned: () => void;
|
||||
onAssign: (jailName: string, payload: AssignFilterRequest, reload: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that contains the dialog body and only renders when open.
|
||||
* This ensures useJails() is only called when the dialog is actually visible.
|
||||
*/
|
||||
function AssignFilterDialogInner({
|
||||
filterName,
|
||||
onClose,
|
||||
onAssigned,
|
||||
onAssign,
|
||||
}: AssignFilterDialogInnerProps): React.JSX.Element {
|
||||
const { jails, loading: jailsLoading, error: jailsError } = useJails();
|
||||
const [selectedJail, setSelectedJail] = useState("");
|
||||
const [reload, setReload] = useState(false);
|
||||
@@ -77,17 +113,16 @@ export function AssignFilterDialog({
|
||||
const activeJails = jails.filter((j) => j.enabled);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setSelectedJail("");
|
||||
setReload(false);
|
||||
}, [open]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (jailsError && open) {
|
||||
if (jailsError) {
|
||||
setError(jailsError);
|
||||
}
|
||||
}, [jailsError, open]);
|
||||
}, [jailsError]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (submitting) return;
|
||||
@@ -119,87 +154,83 @@ export function AssignFilterDialog({
|
||||
const canConfirm = selectedJail !== "" && !submitting && !jailsLoading;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Assign Filter to Jail</DialogTitle>
|
||||
<DialogContent>
|
||||
{filterName !== null && (
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Assign filter{" "}
|
||||
<Text weight="semibold" style={{ fontFamily: "monospace" }}>
|
||||
{filterName}
|
||||
</Text>{" "}
|
||||
to a jail. This writes{" "}
|
||||
<Text style={{ fontFamily: "monospace" }}>filter = {filterName}</Text>{" "}
|
||||
into the jail's <code>.local</code> override file.
|
||||
</Text>
|
||||
)}
|
||||
<DialogBody>
|
||||
<DialogTitle>Assign Filter to Jail</DialogTitle>
|
||||
<DialogContent>
|
||||
{filterName !== null && (
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Assign filter{" "}
|
||||
<Text weight="semibold" style={{ fontFamily: "monospace" }}>
|
||||
{filterName}
|
||||
</Text>{" "}
|
||||
to a jail. This writes{" "}
|
||||
<Text style={{ fontFamily: "monospace" }}>filter = {filterName}</Text>{" "}
|
||||
into the jail's <code>.local</code> override file.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{error !== null && (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Target jail"
|
||||
required
|
||||
hint="Only currently enabled jails are listed."
|
||||
<Field
|
||||
label="Target jail"
|
||||
required
|
||||
hint="Only currently enabled jails are listed."
|
||||
>
|
||||
{jailsLoading ? (
|
||||
<Spinner size="extra-small" label="Loading jails…" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedJail}
|
||||
onChange={(_e, d) => { setSelectedJail(d.value); }}
|
||||
aria-label="Target jail"
|
||||
>
|
||||
{jailsLoading ? (
|
||||
<Spinner size="extra-small" label="Loading jails…" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedJail}
|
||||
onChange={(_e, d) => { setSelectedJail(d.value); }}
|
||||
aria-label="Target jail"
|
||||
>
|
||||
<option value="" disabled>
|
||||
— select a jail —
|
||||
</option>
|
||||
{activeJails.map((j) => (
|
||||
<option key={j.name} value={j.name}>
|
||||
{j.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<option value="" disabled>
|
||||
— select a jail —
|
||||
</option>
|
||||
{activeJails.map((j) => (
|
||||
<option key={j.name} value={j.name}>
|
||||
{j.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Checkbox
|
||||
label="Reload fail2ban after assigning"
|
||||
checked={reload}
|
||||
onChange={(_e, d) => { setReload(Boolean(d.checked)); }}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
icon={submitting ? <Spinner size="extra-small" /> : undefined}
|
||||
>
|
||||
{submitting ? "Assigning…" : "Assign"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
<Checkbox
|
||||
label="Reload fail2ban after assigning"
|
||||
checked={reload}
|
||||
onChange={(_e, d) => { setReload(Boolean(d.checked)); }}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm}
|
||||
icon={submitting ? <Spinner size="extra-small" /> : undefined}
|
||||
>
|
||||
{submitting ? "Assigning…" : "Assign"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,9 @@ function renderDialog(overrides: Partial<React.ComponentProps<typeof AssignFilte
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onAssigned: vi.fn(),
|
||||
onAssign: vi.fn(async () => undefined),
|
||||
onAssign: vi.fn(async (jailName, payload, reload) => {
|
||||
await assignFilterToJail(jailName, payload, reload);
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
return render(
|
||||
|
||||
Reference in New Issue
Block a user