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:
2026-04-23 08:18:19 +02:00
parent 97d47fae81
commit 1510dfc851
4 changed files with 234 additions and 213 deletions

View File

@@ -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&apos;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&apos;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>
);
}

View File

@@ -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&apos;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&apos;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>
);
}

View File

@@ -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(