From 2f60b0915ec415fa1787fe4a990d0bba37761778 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 13 Mar 2026 18:46:45 +0100 Subject: [PATCH] Redesign FiltersTab with active/inactive layout and assign/create dialogs (Tasks 2.3/2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite FiltersTab: use fetchFilters() for FilterConfig[] with embedded active status; show 'Active — sshd, apache-auth' badge labels; FilterDetail sub- component with source_file/override badges, FilterForm, Assign button, raw config section - New AssignFilterDialog: selects jail from enabled-jails list, calls POST /config/jails/{name}/filter with optional fail2ban reload - New CreateFilterDialog: name+failregex+ignoreregex form, calls POST /config/filters, closes and selects new filter on success - Extend ConfigListDetail: add listHeader (for Create button) and itemBadgeLabel (for custom badge text) optional props - Fix updateFilterFile bug: was PUT /config/filters/{name} (structured endpoint), now correctly PUT /config/filters/{name}/raw - Fix createFilterFile bug: was POST /config/filters, now POST /config/filters/raw - Add updateFilter, createFilter, deleteFilter, assignFilterToJail to api/config.ts - Add FilterUpdateRequest, FilterCreateRequest, AssignFilterRequest to types/config.ts - Add configFiltersRaw, configJailFilter endpoints - Tests: 24 new tests across FiltersTab, AssignFilterDialog, CreateFilterDialog (all 89 frontend tests passing) --- Docs/Tasks.md | 4 +- frontend/src/api/config.ts | 74 ++++- frontend/src/api/endpoints.ts | 3 + .../components/config/AssignFilterDialog.tsx | 211 ++++++++++++++ .../components/config/ConfigListDetail.tsx | 23 +- .../components/config/CreateFilterDialog.tsx | 216 ++++++++++++++ frontend/src/components/config/FiltersTab.tsx | 270 +++++++++++++----- .../__tests__/AssignFilterDialog.test.tsx | 190 ++++++++++++ .../__tests__/CreateFilterDialog.test.tsx | 171 +++++++++++ .../config/__tests__/FiltersTab.test.tsx | 233 +++++++++++++++ frontend/src/components/config/index.ts | 4 + frontend/src/types/config.ts | 42 +++ 12 files changed, 1358 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/config/AssignFilterDialog.tsx create mode 100644 frontend/src/components/config/CreateFilterDialog.tsx create mode 100644 frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx create mode 100644 frontend/src/components/config/__tests__/CreateFilterDialog.test.tsx create mode 100644 frontend/src/components/config/__tests__/FiltersTab.test.tsx diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 277ec71..73be16e 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -160,7 +160,7 @@ fail2ban ships with a large collection of filter definitions in `filter.d/` (ove --- -### Task 2.3 — Frontend: Filters Tab with Active/Inactive Display and Activation +### Task 2.3 — Frontend: Filters Tab with Active/Inactive Display and Activation ✅ DONE **Goal:** Enhance the Filters tab in the Configuration page to show all filters with their active/inactive status and allow editing. @@ -187,7 +187,7 @@ fail2ban ships with a large collection of filter definitions in `filter.d/` (ove --- -### Task 2.4 — Tests: Filter Discovery and Management +### Task 2.4 — Tests: Filter Discovery and Management ✅ DONE **Goal:** Test coverage for filter listing, editing, creation, and assignment. diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 7933f3d..b33046a 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -9,13 +9,16 @@ import type { ActionConfigUpdate, ActivateJailRequest, AddLogPathRequest, + AssignFilterRequest, ConfFileContent, ConfFileCreateRequest, ConfFilesResponse, ConfFileUpdateRequest, FilterConfig, FilterConfigUpdate, + FilterCreateRequest, FilterListResponse, + FilterUpdateRequest, GlobalConfig, GlobalConfigUpdate, InactiveJailListResponse, @@ -224,17 +227,19 @@ export async function fetchFilterFile(name: string): Promise { return get(ENDPOINTS.configFilterRaw(name)); } +/** Save raw content to a filter definition file (``PUT /filters/{name}/raw``). */ export async function updateFilterFile( name: string, req: ConfFileUpdateRequest ): Promise { - await put(ENDPOINTS.configFilter(name), req); + await put(ENDPOINTS.configFilterRaw(name), req); } +/** Create a new raw filter file (``POST /filters/raw``). */ export async function createFilterFile( req: ConfFileCreateRequest ): Promise { - return post(ENDPOINTS.configFilters, req); + return post(ENDPOINTS.configFiltersRaw, req); } // --------------------------------------------------------------------------- @@ -263,7 +268,7 @@ export async function createActionFile( } // --------------------------------------------------------------------------- -// Parsed filter config (Task 2.2) +// Parsed filter config (Task 2.2 / legacy /parsed endpoint) // --------------------------------------------------------------------------- export async function fetchParsedFilter(name: string): Promise { @@ -277,6 +282,69 @@ export async function updateParsedFilter( await put(ENDPOINTS.configFilterParsed(name), update); } +// --------------------------------------------------------------------------- +// Filter structured update / create / delete (Task 2.3) +// --------------------------------------------------------------------------- + +/** + * Update a filter'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 - Filter base name (e.g. ``"sshd"``) + * @param req - Partial update payload. + */ +export async function updateFilter( + name: string, + req: FilterUpdateRequest +): Promise { + await put(ENDPOINTS.configFilter(name), req); +} + +/** + * Create a brand-new user-defined filter in ``filter.d/{name}.local``. + * + * @param req - Name and optional regex patterns. + * @returns The newly created FilterConfig. + */ +export async function createFilter( + req: FilterCreateRequest +): Promise { + return post(ENDPOINTS.configFilters, req); +} + +/** + * Delete a filter's ``.local`` override file. + * + * Only custom ``.local``-only filters can be deleted. Attempting to delete a + * filter that is backed by a shipped ``.conf`` file returns 409. + * + * @param name - Filter base name. + */ +export async function deleteFilter(name: string): Promise { + await del(ENDPOINTS.configFilter(name)); +} + +/** + * Assign a filter to a jail by writing ``filter = {filter_name}`` to the + * jail's ``.local`` config file. + * + * @param jailName - Jail name. + * @param req - The filter to assign. + * @param reload - When ``true``, trigger a fail2ban reload after writing. + */ +export async function assignFilterToJail( + jailName: string, + req: AssignFilterRequest, + reload = false +): Promise { + const url = reload + ? `${ENDPOINTS.configJailFilter(jailName)}?reload=true` + : ENDPOINTS.configJailFilter(jailName); + await post(url, req); +} + // --------------------------------------------------------------------------- // Filter discovery with active/inactive status (Task 2.1) // --------------------------------------------------------------------------- diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 2528eae..cff22a6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -84,10 +84,13 @@ export const ENDPOINTS = { configJailFileParsed: (filename: string): string => `/config/jail-files/${encodeURIComponent(filename)}/parsed`, configFilters: "/config/filters", + configFiltersRaw: "/config/filters/raw", configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`, configFilterRaw: (name: string): string => `/config/filters/${encodeURIComponent(name)}/raw`, configFilterParsed: (name: string): string => `/config/filters/${encodeURIComponent(name)}/parsed`, + configJailFilter: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/filter`, configActions: "/config/actions", configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`, configActionParsed: (name: string): string => diff --git a/frontend/src/components/config/AssignFilterDialog.tsx b/frontend/src/components/config/AssignFilterDialog.tsx new file mode 100644 index 0000000..cb704d2 --- /dev/null +++ b/frontend/src/components/config/AssignFilterDialog.tsx @@ -0,0 +1,211 @@ +/** + * AssignFilterDialog — dialog for assigning a filter to a fail2ban jail. + * + * Fetches the list of active jails for the dropdown, then calls + * ``POST /api/config/jails/{jail_name}/filter`` 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 { assignFilterToJail } from "../../api/config"; +import { fetchJails } from "../../api/jails"; +import type { AssignFilterRequest } from "../../types/config"; +import type { JailSummary } from "../../types/jail"; +import { ApiError } from "../../api/client"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AssignFilterDialogProps { + /** + * The filter base name to assign (e.g. ``"sshd"``). Set to ``null`` when + * the dialog is closed. + */ + filterName: string | null; + /** Whether the dialog is currently open. */ + open: boolean; + /** Called when the dialog should close without taking action. */ + onClose: () => void; + /** Called after the filter has been successfully assigned. */ + onAssigned: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * 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. + * + * @param props - Component props. + * @returns JSX element. + */ +export function AssignFilterDialog({ + filterName, + open, + onClose, + onAssigned, +}: AssignFilterDialogProps): 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 (!filterName || !selectedJail || submitting) return; + + const req: AssignFilterRequest = { filter_name: filterName }; + setSubmitting(true); + setError(null); + + assignFilterToJail(selectedJail, req, reload) + .then(() => { + onAssigned(); + }) + .catch((err: unknown) => { + setError( + err instanceof ApiError ? err.message : "Failed to assign filter.", + ); + }) + .finally(() => { + setSubmitting(false); + }); + }, [filterName, selectedJail, reload, submitting, onAssigned]); + + const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; + + return ( + { if (!data.open) handleClose(); }}> + + + Assign Filter to Jail + + {filterName !== null && ( + + Assign filter{" "} + + {filterName} + {" "} + to a jail. This writes{" "} + filter = {filterName}{" "} + into the jail's .local override file. + + )} + + {error !== null && ( + + {error} + + )} + + + {jailsLoading ? ( + + ) : ( + + )} + + + { setReload(Boolean(d.checked)); }} + style={{ marginTop: tokens.spacingVerticalS }} + /> + + + + + + + + + ); +} diff --git a/frontend/src/components/config/ConfigListDetail.tsx b/frontend/src/components/config/ConfigListDetail.tsx index c041fc0..a740ce4 100644 --- a/frontend/src/components/config/ConfigListDetail.tsx +++ b/frontend/src/components/config/ConfigListDetail.tsx @@ -47,6 +47,16 @@ export interface ConfigListDetailProps { error: string | null; /** Detail pane content for the currently selected item. */ children: React.ReactNode; + /** + * Optional content rendered at the top of the list pane (e.g. a "Create" + * button). Visible on all screen sizes above the item list. + */ + listHeader?: React.ReactNode; + /** + * Optional callback to override the badge text for an item. When omitted + * the badge shows ``"Active"`` or ``"Inactive"``. + */ + itemBadgeLabel?: (item: T) => string; } /** Number of skeleton placeholder rows shown while loading. */ @@ -93,6 +103,8 @@ export function ConfigListDetail({ loading, error, children, + listHeader, + itemBadgeLabel, }: ConfigListDetailProps): React.JSX.Element { const styles = useConfigStyles(); @@ -212,6 +224,10 @@ export function ConfigListDetail({ onKeyDown={handleKeyDown} ref={listRef} > + {/* Optional header content (e.g. a Create button) */} + {listHeader !== undefined && ( +
{listHeader}
+ )} {/* Responsive dropdown (visible only on narrow screens via CSS) */}
({ const active = isActive(item); const selected = item.name === selectedName; const needsTooltip = item.name.length > TOOLTIP_THRESHOLD; + const badgeText = itemBadgeLabel + ? itemBadgeLabel(item) + : active + ? "Active" + : "Inactive"; const itemEl = (
({ size="small" style={{ flexShrink: 0 }} > - {active ? "Active" : "Inactive"} + {badgeText}
); diff --git a/frontend/src/components/config/CreateFilterDialog.tsx b/frontend/src/components/config/CreateFilterDialog.tsx new file mode 100644 index 0000000..cabd28d --- /dev/null +++ b/frontend/src/components/config/CreateFilterDialog.tsx @@ -0,0 +1,216 @@ +/** + * CreateFilterDialog — dialog for creating a new user-defined fail2ban filter. + * + * Asks for a filter name and optional failregex / ignoreregex patterns, then + * calls ``POST /api/config/filters`` 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 { createFilter } from "../../api/config"; +import type { FilterConfig, FilterCreateRequest } from "../../types/config"; +import { ApiError } from "../../api/client"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CreateFilterDialogProps { + /** Whether the dialog is currently open. */ + open: boolean; + /** Called when the dialog should close without taking action. */ + onClose: () => void; + /** + * Called after the filter has been successfully created. + * + * @param filter - The newly created FilterConfig returned by the API. + */ + onCreate: (filter: FilterConfig) => void; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Split a multiline string into non-empty trimmed lines. */ +function splitLines(value: string): string[] { + return value + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Dialog for creating a new filter in ``filter.d/{name}.local``. + * + * The name field accepts a plain base name (e.g. ``my-app``); the ``.local`` + * extension is added by the backend. failregex and ignoreregex are entered as + * one pattern per line. + * + * @param props - Component props. + * @returns JSX element. + */ +export function CreateFilterDialog({ + open, + onClose, + onCreate, +}: CreateFilterDialogProps): React.JSX.Element { + const [name, setName] = useState(""); + const [failregex, setFailregex] = useState(""); + const [ignoreregex, setIgnoreregex] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset form when the dialog opens. + useEffect(() => { + if (open) { + setName(""); + setFailregex(""); + setIgnoreregex(""); + 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: FilterCreateRequest = { + name: trimmedName, + failregex: splitLines(failregex), + ignoreregex: splitLines(ignoreregex), + }; + + setSubmitting(true); + setError(null); + + createFilter(req) + .then((filter) => { + onCreate(filter); + }) + .catch((err: unknown) => { + setError( + err instanceof ApiError ? err.message : "Failed to create filter.", + ); + }) + .finally(() => { + setSubmitting(false); + }); + }, [name, failregex, ignoreregex, submitting, onCreate]); + + const canConfirm = name.trim() !== "" && !submitting; + + return ( + { if (!data.open) handleClose(); }}> + + + Create Filter + + + Creates a new filter definition at{" "} + filter.d/<name>.local. + + + {error !== null && ( + + {error} + + )} + + + { setName(d.value); }} + placeholder="my-app" + disabled={submitting} + /> + + + +