Redesign FiltersTab with active/inactive layout and assign/create dialogs (Tasks 2.3/2.4)

- 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)
This commit is contained in:
2026-03-13 18:46:45 +01:00
parent e15ad8fb62
commit 2f60b0915e
12 changed files with 1358 additions and 83 deletions

View File

@@ -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. **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. **Goal:** Test coverage for filter listing, editing, creation, and assignment.

View File

@@ -9,13 +9,16 @@ import type {
ActionConfigUpdate, ActionConfigUpdate,
ActivateJailRequest, ActivateJailRequest,
AddLogPathRequest, AddLogPathRequest,
AssignFilterRequest,
ConfFileContent, ConfFileContent,
ConfFileCreateRequest, ConfFileCreateRequest,
ConfFilesResponse, ConfFilesResponse,
ConfFileUpdateRequest, ConfFileUpdateRequest,
FilterConfig, FilterConfig,
FilterConfigUpdate, FilterConfigUpdate,
FilterCreateRequest,
FilterListResponse, FilterListResponse,
FilterUpdateRequest,
GlobalConfig, GlobalConfig,
GlobalConfigUpdate, GlobalConfigUpdate,
InactiveJailListResponse, InactiveJailListResponse,
@@ -224,17 +227,19 @@ export async function fetchFilterFile(name: string): Promise<ConfFileContent> {
return get<ConfFileContent>(ENDPOINTS.configFilterRaw(name)); return get<ConfFileContent>(ENDPOINTS.configFilterRaw(name));
} }
/** Save raw content to a filter definition file (``PUT /filters/{name}/raw``). */
export async function updateFilterFile( export async function updateFilterFile(
name: string, name: string,
req: ConfFileUpdateRequest req: ConfFileUpdateRequest
): Promise<void> { ): Promise<void> {
await put<undefined>(ENDPOINTS.configFilter(name), req); await put<undefined>(ENDPOINTS.configFilterRaw(name), req);
} }
/** Create a new raw filter file (``POST /filters/raw``). */
export async function createFilterFile( export async function createFilterFile(
req: ConfFileCreateRequest req: ConfFileCreateRequest
): Promise<ConfFileContent> { ): Promise<ConfFileContent> {
return post<ConfFileContent>(ENDPOINTS.configFilters, req); return post<ConfFileContent>(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<FilterConfig> { export async function fetchParsedFilter(name: string): Promise<FilterConfig> {
@@ -277,6 +282,69 @@ export async function updateParsedFilter(
await put<undefined>(ENDPOINTS.configFilterParsed(name), update); await put<undefined>(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<void> {
await put<undefined>(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<FilterConfig> {
return post<FilterConfig>(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<void> {
await del<undefined>(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<void> {
const url = reload
? `${ENDPOINTS.configJailFilter(jailName)}?reload=true`
: ENDPOINTS.configJailFilter(jailName);
await post<undefined>(url, req);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Filter discovery with active/inactive status (Task 2.1) // Filter discovery with active/inactive status (Task 2.1)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -84,10 +84,13 @@ export const ENDPOINTS = {
configJailFileParsed: (filename: string): string => configJailFileParsed: (filename: string): string =>
`/config/jail-files/${encodeURIComponent(filename)}/parsed`, `/config/jail-files/${encodeURIComponent(filename)}/parsed`,
configFilters: "/config/filters", configFilters: "/config/filters",
configFiltersRaw: "/config/filters/raw",
configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`, configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`,
configFilterRaw: (name: string): string => `/config/filters/${encodeURIComponent(name)}/raw`, configFilterRaw: (name: string): string => `/config/filters/${encodeURIComponent(name)}/raw`,
configFilterParsed: (name: string): string => configFilterParsed: (name: string): string =>
`/config/filters/${encodeURIComponent(name)}/parsed`, `/config/filters/${encodeURIComponent(name)}/parsed`,
configJailFilter: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/filter`,
configActions: "/config/actions", configActions: "/config/actions",
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`, configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
configActionParsed: (name: string): string => configActionParsed: (name: string): string =>

View File

@@ -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<JailSummary[]>([]);
const [jailsLoading, setJailsLoading] = useState(false);
const [selectedJail, setSelectedJail] = useState("");
const [reload, setReload] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<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>
)}
{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."
>
{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>
{jails.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>
);
}

View File

@@ -47,6 +47,16 @@ export interface ConfigListDetailProps<T extends { name: string }> {
error: string | null; error: string | null;
/** Detail pane content for the currently selected item. */ /** Detail pane content for the currently selected item. */
children: React.ReactNode; 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. */ /** Number of skeleton placeholder rows shown while loading. */
@@ -93,6 +103,8 @@ export function ConfigListDetail<T extends { name: string }>({
loading, loading,
error, error,
children, children,
listHeader,
itemBadgeLabel,
}: ConfigListDetailProps<T>): React.JSX.Element { }: ConfigListDetailProps<T>): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
@@ -212,6 +224,10 @@ export function ConfigListDetail<T extends { name: string }>({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
ref={listRef} ref={listRef}
> >
{/* Optional header content (e.g. a Create button) */}
{listHeader !== undefined && (
<div style={{ marginBottom: tokens.spacingVerticalS }}>{listHeader}</div>
)}
{/* Responsive dropdown (visible only on narrow screens via CSS) */} {/* Responsive dropdown (visible only on narrow screens via CSS) */}
<div <div
style={{ style={{
@@ -241,6 +257,11 @@ export function ConfigListDetail<T extends { name: string }>({
const active = isActive(item); const active = isActive(item);
const selected = item.name === selectedName; const selected = item.name === selectedName;
const needsTooltip = item.name.length > TOOLTIP_THRESHOLD; const needsTooltip = item.name.length > TOOLTIP_THRESHOLD;
const badgeText = itemBadgeLabel
? itemBadgeLabel(item)
: active
? "Active"
: "Inactive";
const itemEl = ( const itemEl = (
<div <div
@@ -280,7 +301,7 @@ export function ConfigListDetail<T extends { name: string }>({
size="small" size="small"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
{active ? "Active" : "Inactive"} {badgeText}
</Badge> </Badge>
</div> </div>
); );

View File

@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
<DialogSurface>
<DialogBody>
<DialogTitle>Create Filter</DialogTitle>
<DialogContent>
<Text
as="p"
size={300}
style={{ marginBottom: tokens.spacingVerticalM }}
>
Creates a new filter definition at{" "}
<code>filter.d/&lt;name&gt;.local</code>.
</Text>
{error !== null && (
<MessageBar
intent="error"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
<Field
label="Filter name"
required
hint='Base name without extension, e.g. "my-app". Must not already exist.'
>
<Input
value={name}
onChange={(_e, d) => { setName(d.value); }}
placeholder="my-app"
disabled={submitting}
/>
</Field>
<Field
label="failregex patterns"
hint="One regex pattern per line. Leave blank to add patterns later."
style={{ marginTop: tokens.spacingVerticalS }}
>
<Textarea
value={failregex}
onChange={(_e, d) => { setFailregex(d.value); }}
placeholder={"^%(__prefix_line)s Authentication failure.*<HOST>"}
rows={4}
style={{ fontFamily: "monospace", fontSize: 12 }}
disabled={submitting}
/>
</Field>
<Field
label="ignoreregex patterns"
hint="Patterns that bypass ban logic. One per line."
style={{ marginTop: tokens.spacingVerticalS }}
>
<Textarea
value={ignoreregex}
onChange={(_e, d) => { setIgnoreregex(d.value); }}
placeholder=""
rows={2}
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 Filter"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
}

View File

@@ -1,88 +1,210 @@
/** /**
* FiltersTab — list/detail layout for filter.d file editing. * FiltersTab — master/detail layout for filter.d discovery and editing.
* *
* Left pane: filter names with Active/Inactive badges. Active filters are * Left pane: filter names with Active/Inactive badges (active filters sorted
* those referenced by at least one running jail. Right pane: structured form * to the top). Active badge also shows which jails reference the filter.
* editor plus a collapsible raw-config editor. * Right pane: structured filter editor, "Assign to Jail" button, and a
* collapsible raw-config editor.
*
* A "Create Filter" button at the top of the list pane opens a dialog for
* creating a new ``filter.d/*.local`` file.
*/ */
import { useCallback, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Badge,
Button, Button,
Field,
Input,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
Skeleton, Skeleton,
SkeletonItem, SkeletonItem,
Text,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { DocumentAdd24Regular } from "@fluentui/react-icons"; import { Add24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
import { fetchFilterFile, fetchFilterFiles, updateFilterFile } from "../../api/config"; import { fetchFilterFile, fetchFilters, updateFilterFile } from "../../api/config";
import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config"; import type { ConfFileUpdateRequest, FilterConfig } from "../../types/config";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { AssignFilterDialog } from "./AssignFilterDialog";
import { ConfigListDetail } from "./ConfigListDetail"; import { ConfigListDetail } from "./ConfigListDetail";
import { CreateFilterDialog } from "./CreateFilterDialog";
import { FilterForm } from "./FilterForm"; import { FilterForm } from "./FilterForm";
import { RawConfigSection } from "./RawConfigSection"; import { RawConfigSection } from "./RawConfigSection";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** /**
* Tab component for the form-based filter.d editor. * Build the badge label text shown next to each filter in the list pane.
*
* Active filters that are used by one or more jails display the jail names,
* e.g. ``"Active — sshd, apache-auth"``.
*/
function filterBadgeLabel(f: FilterConfig): string {
if (!f.active) return "Inactive";
if (f.used_by_jails.length === 0) return "Active";
return `Active — ${f.used_by_jails.join(", ")}`;
}
// ---------------------------------------------------------------------------
// FilterDetail — right-pane detail for a selected filter
// ---------------------------------------------------------------------------
interface FilterDetailProps {
filter: FilterConfig;
onAssignClick: () => void;
}
/**
* Detail pane for a selected filter: shows meta information, the structured
* editor, an "Assign to Jail" action, and a raw-config section.
*
* @param props - Component props.
* @returns JSX element.
*/
function FilterDetail({
filter,
onAssignClick,
}: FilterDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const fetchRaw = useCallback(async (): Promise<string> => {
const result = await fetchFilterFile(filter.name);
return result.content;
}, [filter.name]);
const saveRaw = useCallback(
async (content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content };
await updateFilterFile(filter.name, req);
},
[filter.name],
);
return (
<div>
{/* Meta information row */}
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
<Field label="Source file">
<Input
readOnly
value={filter.source_file || filter.filename}
className={styles.codeInput}
size="small"
/>
</Field>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Badge
appearance={filter.active ? "filled" : "outline"}
color={filter.active ? "success" : "informative"}
>
{filter.active ? "Active" : "Inactive"}
</Badge>
{filter.has_local_override && (
<Badge appearance="tint" color="warning" size="small">
Has local override
</Badge>
)}
</div>
</div>
{/* Structured editor */}
<FilterForm name={filter.name} />
{/* Assign to jail action */}
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="secondary"
icon={<LinkEdit24Regular />}
onClick={onAssignClick}
>
Assign to Jail
</Button>
</div>
{/* Raw config */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
label="Raw Filter Configuration"
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// FiltersTab (public export)
// ---------------------------------------------------------------------------
/**
* Tab component for exploring and editing fail2ban filter definitions.
* *
* @returns JSX element. * @returns JSX element.
*/ */
export function FiltersTab(): React.JSX.Element { export function FiltersTab(): React.JSX.Element {
const styles = useConfigStyles(); const [filters, setFilters] = useState<FilterConfig[]>([]);
const [files, setFiles] = useState<ConfFileEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedName, setSelectedName] = useState<string | null>(null); const [selectedName, setSelectedName] = useState<string | null>(null);
const { activeFilters, loading: statusLoading } = useConfigActiveStatus(); const [assignOpen, setAssignOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
useEffect(() => { const loadFilters = useCallback((): void => {
abortRef.current?.abort(); abortRef.current?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
abortRef.current = ctrl; abortRef.current = ctrl;
setLoading(true); setLoading(true);
setError(null);
fetchFilterFiles() fetchFilters()
.then((resp) => { .then((resp) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setFiles(resp.files); setFilters(resp.filters);
setLoading(false); setLoading(false);
} }
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!ctrl.signal.aborted) { if (!ctrl.signal.aborted) {
setError( setError(err instanceof Error ? err.message : "Failed to load filters");
err instanceof Error ? err.message : "Failed to load filters",
);
setLoading(false); setLoading(false);
} }
}); });
return (): void => {
ctrl.abort();
};
}, []); }, []);
const fetchRaw = useCallback( useEffect(() => {
async (name: string): Promise<string> => { loadFilters();
const result = await fetchFilterFile(name); return (): void => {
return result.content; abortRef.current?.abort();
};
}, [loadFilters]);
/** The full FilterConfig for the currently selected name. */
const selectedFilter = useMemo(
() => filters.find((f) => f.name === selectedName) ?? null,
[filters, selectedName],
);
const handleAssigned = useCallback((): void => {
setAssignOpen(false);
// Refresh filter list so active status is up-to-date.
loadFilters();
}, [loadFilters]);
const handleCreated = useCallback(
(newFilter: FilterConfig): void => {
setCreateOpen(false);
setFilters((prev) => [...prev, newFilter]);
setSelectedName(newFilter.name);
}, },
[], [],
); );
const saveRaw = useCallback( if (loading) {
async (name: string, content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content };
await updateFilterFile(name, req);
},
[],
);
if (loading || statusLoading) {
return ( return (
<Skeleton aria-label="Loading filters…"> <Skeleton aria-label="Loading filters…">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
@@ -100,55 +222,49 @@ export function FiltersTab(): React.JSX.Element {
); );
} }
if (files.length === 0) { const listHeader = (
return ( <Button
<div className={styles.emptyState}> appearance="outline"
<DocumentAdd24Regular icon={<Add24Regular />}
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }} size="small"
aria-hidden onClick={() => { setCreateOpen(true); }}
/> >
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}> Create Filter
No filter files found. </Button>
</Text> );
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Create a new filter file in the Export tab.
</Text>
<Button
appearance="primary"
onClick={() => {
window.location.hash = "#export";
}}
>
Go to Export
</Button>
</div>
);
}
return ( return (
<div className={styles.tabContent}> <>
<ConfigListDetail <ConfigListDetail
items={files} items={filters}
isActive={(f) => activeFilters.has(f.name)} isActive={(f) => f.active}
itemBadgeLabel={filterBadgeLabel}
selectedName={selectedName} selectedName={selectedName}
onSelect={setSelectedName} onSelect={setSelectedName}
loading={false} loading={false}
error={null} error={null}
listHeader={listHeader}
> >
{selectedName !== null && ( {selectedFilter !== null && (
<div> <FilterDetail
<FilterForm name={selectedName} /> filter={selectedFilter}
<div style={{ marginTop: tokens.spacingVerticalL }}> onAssignClick={() => { setAssignOpen(true); }}
<RawConfigSection />
fetchContent={() => fetchRaw(selectedName)}
saveContent={(content) => saveRaw(selectedName, content)}
label="Raw Filter Configuration"
/>
</div>
</div>
)} )}
</ConfigListDetail> </ConfigListDetail>
</div>
<AssignFilterDialog
filterName={selectedName}
open={assignOpen}
onClose={() => { setAssignOpen(false); }}
onAssigned={handleAssigned}
/>
<CreateFilterDialog
open={createOpen}
onClose={() => { setCreateOpen(false); }}
onCreate={handleCreated}
/>
</>
); );
} }

View File

@@ -0,0 +1,190 @@
/**
* Tests for the AssignFilterDialog component (Task 2.3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { AssignFilterDialog } from "../AssignFilterDialog";
import type { JailListResponse } from "../../../types/jail";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
assignFilterToJail: vi.fn(),
}));
vi.mock("../../../api/jails", () => ({
fetchJails: vi.fn(),
}));
import { assignFilterToJail } from "../../../api/config";
import { fetchJails } from "../../../api/jails";
const mockAssignFilterToJail = vi.mocked(assignFilterToJail);
const mockFetchJails = vi.mocked(fetchJails);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const mockJailsResponse: JailListResponse = {
jails: [
{
name: "sshd",
enabled: true,
running: true,
idle: false,
backend: "systemd",
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: null,
},
{
name: "apache-auth",
enabled: false,
running: false,
idle: false,
backend: "polling",
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: null,
},
],
total: 2,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderDialog(overrides: Partial<React.ComponentProps<typeof AssignFilterDialog>> = {}) {
const props = {
filterName: "nginx",
open: true,
onClose: vi.fn(),
onAssigned: vi.fn(),
...overrides,
};
return render(
<FluentProvider theme={webLightTheme}>
<AssignFilterDialog {...props} />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AssignFilterDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchJails.mockResolvedValue(mockJailsResponse);
});
it("renders dialog title when open", async () => {
renderDialog();
await waitFor(() => {
expect(screen.getByText(/assign filter to jail/i)).toBeInTheDocument();
});
});
it("shows the filter name in the dialog body", async () => {
renderDialog({ filterName: "my-filter" });
await waitFor(() => {
expect(screen.getByText("my-filter")).toBeInTheDocument();
});
});
it("fetches jails when dialog opens", async () => {
renderDialog();
await waitFor(() => {
expect(mockFetchJails).toHaveBeenCalledTimes(1);
});
});
it("only shows enabled jails in the dropdown", async () => {
renderDialog();
await waitFor(() => {
// sshd is enabled, apache-auth is not
expect(screen.getByRole("option", { name: "sshd" })).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: "apache-auth" }),
).not.toBeInTheDocument();
});
});
it("calls onClose when Cancel is clicked", async () => {
const onClose = vi.fn();
renderDialog({ onClose });
await waitFor(() => {
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("Assign button is disabled when no jail is selected", async () => {
renderDialog();
await waitFor(() => {
const assignBtn = screen.getByRole("button", { name: /^assign$/i });
expect(assignBtn).toBeDisabled();
});
});
it("calls assignFilterToJail and onAssigned on confirm", async () => {
const onAssigned = vi.fn();
mockAssignFilterToJail.mockResolvedValue(undefined);
renderDialog({ filterName: "nginx", onAssigned });
// Wait for jails to load.
await waitFor(() => {
expect(screen.getByRole("option", { name: "sshd" })).toBeInTheDocument();
});
// Select a jail.
fireEvent.change(screen.getByRole("combobox"), { target: { value: "sshd" } });
// Click Assign.
fireEvent.click(screen.getByRole("button", { name: /^assign$/i }));
await waitFor(() => {
expect(mockAssignFilterToJail).toHaveBeenCalledWith(
"sshd",
{ filter_name: "nginx" },
false,
);
expect(onAssigned).toHaveBeenCalledTimes(1);
});
});
it("shows error message when assignment fails", async () => {
mockAssignFilterToJail.mockRejectedValue(new Error("Assignment failed"));
renderDialog({ filterName: "nginx" });
await waitFor(() => {
expect(screen.getByRole("option", { name: "sshd" })).toBeInTheDocument();
});
fireEvent.change(screen.getByRole("combobox"), { target: { value: "sshd" } });
fireEvent.click(screen.getByRole("button", { name: /^assign$/i }));
await waitFor(() => {
expect(screen.getByText(/failed to assign filter/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,171 @@
/**
* Tests for the CreateFilterDialog component (Task 2.3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { CreateFilterDialog } from "../CreateFilterDialog";
import type { FilterConfig } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
createFilter: vi.fn(),
}));
import { createFilter } from "../../../api/config";
const mockCreateFilter = vi.mocked(createFilter);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const createdFilter: FilterConfig = {
name: "my-app",
filename: "my-app.local",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: ["^<HOST> failed"],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
active: false,
used_by_jails: [],
source_file: "/etc/fail2ban/filter.d/my-app.local",
has_local_override: true,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderDialog(
overrides: Partial<React.ComponentProps<typeof CreateFilterDialog>> = {},
) {
const props = {
open: true,
onClose: vi.fn(),
onCreate: vi.fn(),
...overrides,
};
return render(
<FluentProvider theme={webLightTheme}>
<CreateFilterDialog {...props} />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("CreateFilterDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders dialog title when open", () => {
renderDialog();
expect(screen.getByRole("heading", { name: /create filter/i })).toBeInTheDocument();
});
it("does not render when closed", () => {
renderDialog({ open: false });
expect(screen.queryByText(/create filter/i)).not.toBeInTheDocument();
});
it("Create Filter button is disabled when name is empty", () => {
renderDialog();
// Name field is empty by default.
expect(
screen.getByRole("button", { name: /create filter/i }),
).toBeDisabled();
});
it("Create Filter button is enabled after entering a name", () => {
renderDialog();
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "my-app" } });
expect(
screen.getByRole("button", { name: /create filter/i }),
).not.toBeDisabled();
});
it("calls onClose when Cancel is clicked", () => {
const onClose = vi.fn();
renderDialog({ onClose });
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls createFilter and onCreate on submit", async () => {
const onCreate = vi.fn();
mockCreateFilter.mockResolvedValue(createdFilter);
renderDialog({ onCreate });
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "my-app" } });
fireEvent.click(screen.getByRole("button", { name: /create filter/i }));
await waitFor(() => {
expect(mockCreateFilter).toHaveBeenCalledWith({
name: "my-app",
failregex: [],
ignoreregex: [],
});
expect(onCreate).toHaveBeenCalledWith(createdFilter);
});
});
it("shows error message when createFilter fails", async () => {
mockCreateFilter.mockRejectedValue(new Error("Name already exists"));
renderDialog();
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "sshd" } });
fireEvent.click(screen.getByRole("button", { name: /create filter/i }));
await waitFor(() => {
expect(screen.getByText(/failed to create filter/i)).toBeInTheDocument();
});
});
it("splits failregex textarea into lines on submit", async () => {
const onCreate = vi.fn();
mockCreateFilter.mockResolvedValue(createdFilter);
renderDialog({ onCreate });
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "my-app" } });
const failregexArea = screen.getByRole("textbox", { name: /failregex/i });
fireEvent.change(failregexArea, {
target: { value: "^<HOST> failed\n^<HOST> error" },
});
fireEvent.click(screen.getByRole("button", { name: /create filter/i }));
await waitFor(() => {
expect(mockCreateFilter).toHaveBeenCalledWith(
expect.objectContaining({
failregex: ["^<HOST> failed", "^<HOST> error"],
}),
);
});
});
});

View File

@@ -0,0 +1,233 @@
/**
* Tests for the redesigned FiltersTab component (Task 2.3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { FiltersTab } from "../FiltersTab";
import type { FilterConfig, FilterListResponse } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchFilters: vi.fn(),
fetchFilterFile: vi.fn(),
updateFilterFile: vi.fn(),
}));
// Mock child components to isolate FiltersTab rendering.
vi.mock("../FilterForm", () => ({
FilterForm: ({ name }: { name: string }) => (
<div data-testid={`filter-form-${name}`}>FilterForm:{name}</div>
),
}));
vi.mock("../RawConfigSection", () => ({
RawConfigSection: () => <div data-testid="raw-config-section">RawConfig</div>,
}));
vi.mock("../AssignFilterDialog", () => ({
AssignFilterDialog: ({ open }: { open: boolean }) =>
open ? <div data-testid="assign-dialog">AssignDialog</div> : null,
}));
vi.mock("../CreateFilterDialog", () => ({
CreateFilterDialog: ({ open }: { open: boolean }) =>
open ? <div data-testid="create-dialog">CreateDialog</div> : null,
}));
import { fetchFilters } from "../../../api/config";
const mockFetchFilters = vi.mocked(fetchFilters);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const activeFilter: FilterConfig = {
name: "sshd",
filename: "sshd.conf",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: ["^<HOST> port \\d+"],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
active: true,
used_by_jails: ["sshd"],
source_file: "/etc/fail2ban/filter.d/sshd.conf",
has_local_override: false,
};
const inactiveFilter: FilterConfig = {
name: "apache-auth",
filename: "apache-auth.conf",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: [],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
active: false,
used_by_jails: [],
source_file: "/etc/fail2ban/filter.d/apache-auth.conf",
has_local_override: false,
};
const mockListResponse: FilterListResponse = {
filters: [activeFilter, inactiveFilter],
total: 2,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTab() {
return render(
<FluentProvider theme={webLightTheme}>
<FiltersTab />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("FiltersTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a loading skeleton while fetching filters", () => {
// Never resolve the promise so loading stays true.
mockFetchFilters.mockReturnValue(new Promise(() => undefined));
renderTab();
expect(screen.getByLabelText(/loading filters/i)).toBeInTheDocument();
});
it("shows an error message when fetchFilters fails", async () => {
mockFetchFilters.mockRejectedValue(new Error("Network error"));
renderTab();
await waitFor(() => {
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
it("renders filter items after successful load", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText("sshd")).toBeInTheDocument();
expect(screen.getByText("apache-auth")).toBeInTheDocument();
});
});
it("shows Active badge text for active filters", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
// Active filter used by "sshd" jail should show "Active — sshd"
expect(screen.getByText(/active — sshd/i)).toBeInTheDocument();
});
});
it("shows Inactive badge text for inactive filters", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText(/inactive/i)).toBeInTheDocument();
});
});
it("shows Create Filter button", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(
screen.getByRole("button", { name: /create filter/i }),
).toBeInTheDocument();
});
});
it("shows placeholder when no filter is selected", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(
screen.getByText(/select an item from the list/i),
).toBeInTheDocument();
});
});
it("shows FilterForm when a filter is selected", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
const { getByText } = renderTab();
await waitFor(() => {
expect(getByText("sshd")).toBeInTheDocument();
});
getByText("sshd").click();
await waitFor(() => {
expect(screen.getByTestId("filter-form-sshd")).toBeInTheDocument();
});
});
it("shows AssignFilterDialog when Assign to Jail is clicked", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
const { getByText } = renderTab();
await waitFor(() => {
expect(getByText("sshd")).toBeInTheDocument();
});
getByText("sshd").click();
await waitFor(() => {
expect(
screen.getByRole("button", { name: /assign to jail/i }),
).toBeInTheDocument();
});
screen.getByRole("button", { name: /assign to jail/i }).click();
await waitFor(() => {
expect(screen.getByTestId("assign-dialog")).toBeInTheDocument();
});
});
it("calls fetchFilters once on mount", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(mockFetchFilters).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -10,12 +10,16 @@ export { ActionForm } from "./ActionForm";
export type { ActionFormProps } from "./ActionForm"; export type { ActionFormProps } from "./ActionForm";
export { ActivateJailDialog } from "./ActivateJailDialog"; export { ActivateJailDialog } from "./ActivateJailDialog";
export type { ActivateJailDialogProps } from "./ActivateJailDialog"; export type { ActivateJailDialogProps } from "./ActivateJailDialog";
export { AssignFilterDialog } from "./AssignFilterDialog";
export type { AssignFilterDialogProps } from "./AssignFilterDialog";
export { AutoSaveIndicator } from "./AutoSaveIndicator"; export { AutoSaveIndicator } from "./AutoSaveIndicator";
export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator"; export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator";
export { ConfFilesTab } from "./ConfFilesTab"; export { ConfFilesTab } from "./ConfFilesTab";
export type { ConfFilesTabProps } from "./ConfFilesTab"; export type { ConfFilesTabProps } from "./ConfFilesTab";
export { ConfigListDetail } from "./ConfigListDetail"; export { ConfigListDetail } from "./ConfigListDetail";
export type { ConfigListDetailProps } from "./ConfigListDetail"; export type { ConfigListDetailProps } from "./ConfigListDetail";
export { CreateFilterDialog } from "./CreateFilterDialog";
export type { CreateFilterDialogProps } from "./CreateFilterDialog";
export { ExportTab } from "./ExportTab"; export { ExportTab } from "./ExportTab";
export { FilterForm } from "./FilterForm"; export { FilterForm } from "./FilterForm";
export type { FilterFormProps } from "./FilterForm"; export type { FilterFormProps } from "./FilterForm";

View File

@@ -460,3 +460,45 @@ export interface JailActivationResponse {
/** Human-readable result message. */ /** Human-readable result message. */
message: string; message: string;
} }
// ---------------------------------------------------------------------------
// Filter write requests (Task 2.3)
// ---------------------------------------------------------------------------
/**
* Payload for ``PUT /api/config/filters/{name}``.
*
* Only the user-editable ``[Definition]`` fields are accepted.
* Fields set to ``null`` or omitted leave the existing value unchanged.
*/
export interface FilterUpdateRequest {
failregex?: string[] | null;
ignoreregex?: string[] | null;
datepattern?: string | null;
journalmatch?: string | null;
}
/**
* Payload for ``POST /api/config/filters``.
*
* Creates a new user-defined filter at ``filter.d/{name}.local``.
*/
export interface FilterCreateRequest {
/** Base name without extension (e.g. ``my-custom-filter``). */
name: string;
failregex?: string[];
ignoreregex?: string[];
prefregex?: string | null;
datepattern?: string | null;
journalmatch?: string | null;
}
/**
* Payload for ``POST /api/config/jails/{jail_name}/filter``.
*
* Assigns a filter to a jail by writing ``filter = {filter_name}`` to the
* jail's ``.local`` config file.
*/
export interface AssignFilterRequest {
filter_name: string;
}