feat: frontend Actions Tab with structured API, assign/create/remove dialogs (Task 3.3)
- ActionsTab rewritten with master/detail layout (mirrors FiltersTab) - New AssignActionDialog and CreateActionDialog components - ActionConfig type extended with active, used_by_jails, source_file, has_local_override - New API functions: fetchActions, fetchAction, updateAction, createAction, deleteAction, assignActionToJail, removeActionFromJail - useActionConfig updated to use new structured endpoints - index.ts barrel exports updated
This commit is contained in:
203
frontend/src/components/config/CreateActionDialog.tsx
Normal file
203
frontend/src/components/config/CreateActionDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
Textarea,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { createAction } from "../../api/config";
|
||||
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 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,
|
||||
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);
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
createAction(req)
|
||||
.then((action) => {
|
||||
onCreate(action);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "Failed to create action.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
}, [name, actionban, actionunban, submitting, onCreate]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user