Add AbortController refs and abort signal checks to prevent race conditions and memory leaks when components unmount or new requests are initiated. Components fixed: - JailsTab.tsx: validation handler with AbortController pattern - JailInfoSection.tsx: handle function with useCallback wrapper - RawConfigSection.tsx: fetch handler with abort checks - ConfFilesTab.tsx: file fetch handler with abort signal verification - IgnoreListSection.tsx: three handlers (add, remove, toggle) with callbacks All handlers now: 1. Abort previous requests before initiating new ones 2. Create and store new AbortController instances 3. Check abort status before state updates in .then()/.catch() 4. Include cleanup effects that abort on unmount Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
224 lines
6.5 KiB
TypeScript
224 lines
6.5 KiB
TypeScript
/**
|
|
* CreateActionDialog — dialog for creating a new user-defined fail2ban action.
|
|
*
|
|
* Asks for an action name and optional lifecycle commands, then calls
|
|
* ``POST /api/config/actions`` on confirmation.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
Button,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogBody,
|
|
DialogContent,
|
|
DialogSurface,
|
|
DialogTitle,
|
|
Field,
|
|
Input,
|
|
MessageBar,
|
|
MessageBarBody,
|
|
Spinner,
|
|
Text,
|
|
Textarea,
|
|
tokens,
|
|
} from "@fluentui/react-components";
|
|
import type { ActionConfig, ActionCreateRequest } from "../../types/config";
|
|
import { ApiError } from "../../api/client";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CreateActionDialogProps {
|
|
/** Whether the dialog is currently open. */
|
|
open: boolean;
|
|
/** Called when the dialog should close without taking action. */
|
|
onClose: () => void;
|
|
/**
|
|
* Called when the form is submitted with valid dialog data.
|
|
*
|
|
* @param payload - Create request payload.
|
|
*/
|
|
onCreateAction: (payload: ActionCreateRequest) => Promise<ActionConfig>;
|
|
/**
|
|
* Called after the action has been successfully created.
|
|
*
|
|
* @param action - The newly created ActionConfig returned by the API.
|
|
*/
|
|
onCreate: (action: ActionConfig) => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Dialog for creating a new action in ``action.d/{name}.local``.
|
|
*
|
|
* The name field accepts a plain base name (e.g. ``my-action``); the ``.local``
|
|
* extension is added by the backend.
|
|
*
|
|
* @param props - Component props.
|
|
* @returns JSX element.
|
|
*/
|
|
export function CreateActionDialog({
|
|
open,
|
|
onClose,
|
|
onCreateAction,
|
|
onCreate,
|
|
}: CreateActionDialogProps): React.JSX.Element {
|
|
const [name, setName] = useState("");
|
|
const [actionban, setActionban] = useState("");
|
|
const [actionunban, setActionunban] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const submitControllerRef = useRef<AbortController | null>(null);
|
|
|
|
// Reset form when the dialog opens.
|
|
useEffect(() => {
|
|
if (open) {
|
|
setName("");
|
|
setActionban("");
|
|
setActionunban("");
|
|
setError(null);
|
|
}
|
|
}, [open]);
|
|
|
|
const handleClose = useCallback((): void => {
|
|
if (submitting) return;
|
|
onClose();
|
|
}, [submitting, onClose]);
|
|
|
|
const handleConfirm = useCallback((): void => {
|
|
const trimmedName = name.trim();
|
|
if (!trimmedName || submitting) return;
|
|
|
|
const req: ActionCreateRequest = {
|
|
name: trimmedName,
|
|
actionban: actionban.trim() || null,
|
|
actionunban: actionunban.trim() || null,
|
|
};
|
|
|
|
submitControllerRef.current?.abort();
|
|
const controller = new AbortController();
|
|
submitControllerRef.current = controller;
|
|
|
|
setSubmitting(true);
|
|
setError(null);
|
|
|
|
onCreateAction(req)
|
|
.then((action) => {
|
|
if (controller.signal.aborted) return;
|
|
onCreate(action);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (controller.signal.aborted) return;
|
|
setError(
|
|
err instanceof ApiError ? err.message : "Failed to create action.",
|
|
);
|
|
})
|
|
.finally(() => {
|
|
if (controller.signal.aborted) return;
|
|
setSubmitting(false);
|
|
});
|
|
}, [name, actionban, actionunban, submitting, onCreate, onCreateAction]);
|
|
|
|
useEffect(() => {
|
|
return (): void => {
|
|
submitControllerRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
const canConfirm = name.trim() !== "" && !submitting;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
|
|
<DialogSurface>
|
|
<DialogBody>
|
|
<DialogTitle>Create Action</DialogTitle>
|
|
<DialogContent>
|
|
<Text
|
|
as="p"
|
|
size={300}
|
|
style={{ marginBottom: tokens.spacingVerticalM }}
|
|
>
|
|
Creates a new action definition at{" "}
|
|
<code>action.d/<name>.local</code>.
|
|
</Text>
|
|
|
|
{error !== null && (
|
|
<MessageBar
|
|
intent="error"
|
|
style={{ marginBottom: tokens.spacingVerticalS }}
|
|
>
|
|
<MessageBarBody>{error}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
<Field
|
|
label="Action name"
|
|
required
|
|
hint='Base name without extension, e.g. "my-action". Must not already exist.'
|
|
>
|
|
<Input
|
|
value={name}
|
|
onChange={(_e, d) => { setName(d.value); }}
|
|
placeholder="my-action"
|
|
disabled={submitting}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label="actionban command"
|
|
hint="Command executed when an IP is banned. Leave blank to add later."
|
|
style={{ marginTop: tokens.spacingVerticalS }}
|
|
>
|
|
<Textarea
|
|
value={actionban}
|
|
onChange={(_e, d) => { setActionban(d.value); }}
|
|
placeholder={"echo ban <ip>"}
|
|
rows={3}
|
|
style={{ fontFamily: "monospace", fontSize: 12 }}
|
|
disabled={submitting}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label="actionunban command"
|
|
hint="Command executed when a ban is removed."
|
|
style={{ marginTop: tokens.spacingVerticalS }}
|
|
>
|
|
<Textarea
|
|
value={actionunban}
|
|
onChange={(_e, d) => { setActionunban(d.value); }}
|
|
placeholder={"echo unban <ip>"}
|
|
rows={3}
|
|
style={{ fontFamily: "monospace", fontSize: 12 }}
|
|
disabled={submitting}
|
|
/>
|
|
</Field>
|
|
</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 ? "Creating…" : "Create"}
|
|
</Button>
|
|
</DialogActions>
|
|
</DialogBody>
|
|
</DialogSurface>
|
|
</Dialog>
|
|
);
|
|
}
|