From 6e35c5d26939de26aa1cc0e8de35fb28505b4925 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 13 Mar 2026 19:21:58 +0100 Subject: [PATCH] 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 --- frontend/src/api/config.ts | 106 ++++++ frontend/src/api/endpoints.ts | 4 + frontend/src/components/config/ActionsTab.tsx | 333 ++++++++++++++---- .../components/config/AssignActionDialog.tsx | 210 +++++++++++ .../components/config/CreateActionDialog.tsx | 203 +++++++++++ .../config/__tests__/ActionForm.test.tsx | 4 + frontend/src/components/config/index.ts | 4 + frontend/src/hooks/useActionConfig.ts | 6 +- frontend/src/types/config.ts | 68 ++++ 9 files changed, 861 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/config/AssignActionDialog.tsx create mode 100644 frontend/src/components/config/CreateActionDialog.tsx diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index b33046a..18ee91d 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -7,8 +7,12 @@ import { ENDPOINTS } from "./endpoints"; import type { ActionConfig, ActionConfigUpdate, + ActionCreateRequest, + ActionListResponse, + ActionUpdateRequest, ActivateJailRequest, AddLogPathRequest, + AssignActionRequest, AssignFilterRequest, ConfFileContent, ConfFileCreateRequest, @@ -386,6 +390,108 @@ export async function updateParsedAction( await put(ENDPOINTS.configActionParsed(name), update); } +// --------------------------------------------------------------------------- +// Action discovery with active/inactive status (Task 3.1 / 3.2) +// --------------------------------------------------------------------------- + +/** + * Fetch all actions from action.d/ with active/inactive status. + * + * Active actions (those referenced by running jails) are returned first, + * followed by inactive ones. Both groups are sorted alphabetically. + * + * @returns ActionListResponse with all discovered actions and status. + */ +export async function fetchActions(): Promise { + return get(ENDPOINTS.configActions); +} + +/** + * Fetch full parsed detail for a single action. + * + * @param name - Action base name (e.g. "iptables" or "iptables.conf"). + * @returns ActionConfig with active, used_by_jails, source_file populated. + */ +export async function fetchAction(name: string): Promise { + return get(ENDPOINTS.configAction(name)); +} + +/** + * Update an action's editable fields via the structured endpoint. + * + * Writes only the supplied fields to the ``.local`` override. Fields set + * to ``null`` are cleared; omitted fields are left unchanged. + * + * @param name - Action base name (e.g. ``"iptables"``) + * @param req - Partial update payload. + */ +export async function updateAction( + name: string, + req: ActionUpdateRequest +): Promise { + await put(ENDPOINTS.configAction(name), req); +} + +/** + * Create a brand-new user-defined action in ``action.d/{name}.local``. + * + * @param req - Name and optional lifecycle commands. + * @returns The newly created ActionConfig. + */ +export async function createAction( + req: ActionCreateRequest +): Promise { + return post(ENDPOINTS.configActions, req); +} + +/** + * Delete an action's ``.local`` override file. + * + * Only custom ``.local``-only actions can be deleted. Attempting to delete an + * action backed by a shipped ``.conf`` file returns 409. + * + * @param name - Action base name. + */ +export async function deleteAction(name: string): Promise { + await del(ENDPOINTS.configAction(name)); +} + +/** + * Assign an action to a jail by appending it to the jail's action list. + * + * @param jailName - Jail name. + * @param req - The action to assign with optional parameters. + * @param reload - When ``true``, trigger a fail2ban reload after writing. + */ +export async function assignActionToJail( + jailName: string, + req: AssignActionRequest, + reload = false +): Promise { + const url = reload + ? `${ENDPOINTS.configJailAction(jailName)}?reload=true` + : ENDPOINTS.configJailAction(jailName); + await post(url, req); +} + +/** + * Remove an action from a jail's action list. + * + * @param jailName - Jail name. + * @param actionName - Action base name to remove. + * @param reload - When ``true``, trigger a fail2ban reload after writing. + */ +export async function removeActionFromJail( + jailName: string, + actionName: string, + reload = false +): Promise { + const url = reload + ? `${ENDPOINTS.configJailActionName(jailName, actionName)}?reload=true` + : ENDPOINTS.configJailActionName(jailName, actionName); + await del(url); +} + // --------------------------------------------------------------------------- // Parsed jail file config (Task 6.1 / 6.2) // --------------------------------------------------------------------------- diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index cff22a6..2801b6f 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -91,6 +91,10 @@ export const ENDPOINTS = { `/config/filters/${encodeURIComponent(name)}/parsed`, configJailFilter: (name: string): string => `/config/jails/${encodeURIComponent(name)}/filter`, + configJailAction: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/action`, + configJailActionName: (jailName: string, actionName: string): string => + `/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`, configActions: "/config/actions", configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`, configActionParsed: (name: string): string => diff --git a/frontend/src/components/config/ActionsTab.tsx b/frontend/src/components/config/ActionsTab.tsx index 580557e..9c349e9 100644 --- a/frontend/src/components/config/ActionsTab.tsx +++ b/frontend/src/components/config/ActionsTab.tsx @@ -1,87 +1,276 @@ /** - * ActionsTab — list/detail layout for action.d file editing. + * ActionsTab — master/detail layout for action.d discovery and editing. * - * Left pane: action names with Active/Inactive badges. Active actions are - * those referenced by at least one running jail. Right pane: structured form - * editor plus a collapsible raw-config editor. + * Left pane: action names with Active/Inactive badges (active actions sorted + * to the top). Active badge also shows which jails reference the action. + * Right pane: structured action editor, "Assign to Jail" button, a + * "Remove from Jail" section, and a collapsible raw-config editor. + * + * A "Create Action" button at the top of the list pane opens a dialog for + * creating a new ``action.d/*.local`` file. */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Badge, Button, + Field, + Input, MessageBar, MessageBarBody, Skeleton, SkeletonItem, - Text, tokens, } from "@fluentui/react-components"; -import { DocumentAdd24Regular } from "@fluentui/react-icons"; -import { fetchActionFile, fetchActionFiles, updateActionFile } from "../../api/config"; -import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config"; -import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; +import { Add24Regular, Delete24Regular, LinkEdit24Regular } from "@fluentui/react-icons"; +import { + fetchActionFile, + fetchActions, + removeActionFromJail, + updateActionFile, +} from "../../api/config"; +import type { ActionConfig, ConfFileUpdateRequest } from "../../types/config"; import { ActionForm } from "./ActionForm"; +import { AssignActionDialog } from "./AssignActionDialog"; import { ConfigListDetail } from "./ConfigListDetail"; +import { CreateActionDialog } from "./CreateActionDialog"; import { RawConfigSection } from "./RawConfigSection"; import { useConfigStyles } from "./configStyles"; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + /** - * Tab component for the form-based action.d editor. + * Build the badge label text shown next to each action in the list pane. + */ +function actionBadgeLabel(a: ActionConfig): string { + if (!a.active) return "Inactive"; + if (a.used_by_jails.length === 0) return "Active"; + return `Active — ${a.used_by_jails.join(", ")}`; +} + +// --------------------------------------------------------------------------- +// ActionDetail — right-pane detail for a selected action +// --------------------------------------------------------------------------- + +interface ActionDetailProps { + action: ActionConfig; + onAssignClick: () => void; + onRemovedFromJail: () => void; +} + +/** + * Detail pane for a selected action: shows meta information, the structured + * editor, assign/remove jail actions, and a raw-config section. + * + * @param props - Component props. + * @returns JSX element. + */ +function ActionDetail({ + action, + onAssignClick, + onRemovedFromJail, +}: ActionDetailProps): React.JSX.Element { + const styles = useConfigStyles(); + const [removingJail, setRemovingJail] = useState(null); + const [removeError, setRemoveError] = useState(null); + + const fetchRaw = useCallback(async (): Promise => { + const result = await fetchActionFile(action.name); + return result.content; + }, [action.name]); + + const saveRaw = useCallback( + async (content: string): Promise => { + const req: ConfFileUpdateRequest = { content }; + await updateActionFile(action.name, req); + }, + [action.name], + ); + + const handleRemoveFromJail = useCallback( + (jailName: string): void => { + setRemovingJail(jailName); + setRemoveError(null); + removeActionFromJail(jailName, action.name) + .then(() => { + onRemovedFromJail(); + }) + .catch((err: unknown) => { + setRemoveError( + err instanceof Error ? err.message : "Failed to remove action from jail.", + ); + }) + .finally(() => { + setRemovingJail(null); + }); + }, + [action.name, onRemovedFromJail], + ); + + return ( +
+ {/* Meta information row */} +
+ + + +
+ + {action.active ? "Active" : "Inactive"} + + {action.has_local_override && ( + + Has local override + + )} +
+
+ + {/* Structured editor */} + + + {/* Assign to jail action */} +
+ +
+ + {/* Remove from jail section */} + {action.used_by_jails.length > 0 && ( +
+ {removeError !== null && ( + + {removeError} + + )} +
+ {action.used_by_jails.map((jailName) => ( +
+ + {jailName} + + +
+ ))} +
+
+ )} + + {/* Raw config */} +
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// ActionsTab (public export) +// --------------------------------------------------------------------------- + +/** + * Tab component for exploring and editing fail2ban action definitions. * * @returns JSX element. */ export function ActionsTab(): React.JSX.Element { - const styles = useConfigStyles(); - const [files, setFiles] = useState([]); + const [actions, setActions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedName, setSelectedName] = useState(null); - const { activeActions, loading: statusLoading } = useConfigActiveStatus(); + const [assignOpen, setAssignOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); const abortRef = useRef(null); - useEffect(() => { + const loadActions = useCallback((): void => { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; setLoading(true); + setError(null); - fetchActionFiles() + fetchActions() .then((resp) => { if (!ctrl.signal.aborted) { - setFiles(resp.files); + setActions(resp.actions); setLoading(false); } }) .catch((err: unknown) => { if (!ctrl.signal.aborted) { - setError( - err instanceof Error ? err.message : "Failed to load actions", - ); + setError(err instanceof Error ? err.message : "Failed to load actions"); setLoading(false); } }); - return (): void => { - ctrl.abort(); - }; }, []); - const fetchRaw = useCallback( - async (name: string): Promise => { - const result = await fetchActionFile(name); - return result.content; + useEffect(() => { + loadActions(); + return (): void => { + abortRef.current?.abort(); + }; + }, [loadActions]); + + /** The full ActionConfig for the currently selected name. */ + const selectedAction = useMemo( + () => actions.find((a) => a.name === selectedName) ?? null, + [actions, selectedName], + ); + + const handleAssigned = useCallback((): void => { + setAssignOpen(false); + // Refresh action list so active status is up-to-date. + loadActions(); + }, [loadActions]); + + const handleCreated = useCallback( + (newAction: ActionConfig): void => { + setCreateOpen(false); + setActions((prev) => [...prev, newAction]); + setSelectedName(newAction.name); }, [], ); - const saveRaw = useCallback( - async (name: string, content: string): Promise => { - const req: ConfFileUpdateRequest = { content }; - await updateActionFile(name, req); - }, - [], - ); + const handleRemovedFromJail = useCallback((): void => { + loadActions(); + }, [loadActions]); - if (loading || statusLoading) { + if (loading) { return ( {[0, 1, 2].map((i) => ( @@ -99,55 +288,51 @@ export function ActionsTab(): React.JSX.Element { ); } - if (files.length === 0) { - return ( -
- - - No action files found. - - - Create a new action file in the Export tab. - - -
- ); - } + const listHeader = ( + + ); return ( -
+ <> activeActions.has(f.name)} + items={actions} + isActive={(a) => a.active} + itemBadgeLabel={actionBadgeLabel} selectedName={selectedName} onSelect={setSelectedName} loading={false} error={null} + listHeader={listHeader} > - {selectedName !== null && ( -
- -
- fetchRaw(selectedName)} - saveContent={(content) => saveRaw(selectedName, content)} - label="Raw Action Configuration" - /> -
-
+ {selectedAction !== null && ( + { setAssignOpen(true); }} + onRemovedFromJail={handleRemovedFromJail} + /> )}
-
+ + { setAssignOpen(false); }} + onAssigned={handleAssigned} + /> + + { setCreateOpen(false); }} + onCreate={handleCreated} + /> + ); } diff --git a/frontend/src/components/config/AssignActionDialog.tsx b/frontend/src/components/config/AssignActionDialog.tsx new file mode 100644 index 0000000..7045186 --- /dev/null +++ b/frontend/src/components/config/AssignActionDialog.tsx @@ -0,0 +1,210 @@ +/** + * AssignActionDialog — dialog for assigning an action to a fail2ban jail. + * + * Fetches the list of active jails for the dropdown, then calls + * ``POST /api/config/jails/{jail_name}/action`` on confirmation. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Field, + MessageBar, + MessageBarBody, + Select, + Spinner, + Text, + tokens, +} from "@fluentui/react-components"; +import { assignActionToJail } from "../../api/config"; +import { fetchJails } from "../../api/jails"; +import type { AssignActionRequest } from "../../types/config"; +import type { JailSummary } from "../../types/jail"; +import { ApiError } from "../../api/client"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AssignActionDialogProps { + /** + * The action base name to assign (e.g. ``"iptables"``). Set to ``null`` + * when the dialog is closed. + */ + actionName: string | null; + /** 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 assigned. */ + onAssigned: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * 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. + * + * @param props - Component props. + * @returns JSX element. + */ +export function AssignActionDialog({ + actionName, + open, + onClose, + onAssigned, +}: AssignActionDialogProps): React.JSX.Element { + const [jails, setJails] = useState([]); + const [jailsLoading, setJailsLoading] = useState(false); + const [selectedJail, setSelectedJail] = useState(""); + const [reload, setReload] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Fetch running jails whenever the dialog opens. + useEffect(() => { + if (!open) return; + + setJailsLoading(true); + setError(null); + setSelectedJail(""); + setReload(false); + + fetchJails() + .then((resp) => { + setJails(resp.jails.filter((j) => j.enabled)); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to load jails."); + }) + .finally(() => { + setJailsLoading(false); + }); + }, [open]); + + const handleClose = useCallback((): void => { + if (submitting) return; + setError(null); + onClose(); + }, [submitting, onClose]); + + const handleConfirm = useCallback((): void => { + if (!actionName || !selectedJail || submitting) return; + + const req: AssignActionRequest = { action_name: actionName }; + setSubmitting(true); + setError(null); + + assignActionToJail(selectedJail, req, reload) + .then(() => { + onAssigned(); + }) + .catch((err: unknown) => { + setError( + err instanceof ApiError ? err.message : "Failed to assign action.", + ); + }) + .finally(() => { + setSubmitting(false); + }); + }, [actionName, selectedJail, reload, submitting, onAssigned]); + + const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; + + return ( + { if (!data.open) handleClose(); }}> + + + Assign Action to Jail + + {actionName !== null && ( + + Assign action{" "} + + {actionName} + {" "} + to a jail. This appends the action to the jail's{" "} + action list in its .local override file. + + )} + + {error !== null && ( + + {error} + + )} + + + {jailsLoading ? ( + + ) : ( + + )} + + + { setReload(Boolean(d.checked)); }} + style={{ marginTop: tokens.spacingVerticalS }} + /> + + + + + + + + + ); +} diff --git a/frontend/src/components/config/CreateActionDialog.tsx b/frontend/src/components/config/CreateActionDialog.tsx new file mode 100644 index 0000000..c162819 --- /dev/null +++ b/frontend/src/components/config/CreateActionDialog.tsx @@ -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(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 ( + { if (!data.open) handleClose(); }}> + + + Create Action + + + Creates a new action definition at{" "} + action.d/<name>.local. + + + {error !== null && ( + + {error} + + )} + + + { setName(d.value); }} + placeholder="my-action" + disabled={submitting} + /> + + + +