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:
@@ -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<ConfFileContent> {
|
||||
return get<ConfFileContent>(ENDPOINTS.configFilterRaw(name));
|
||||
}
|
||||
|
||||
/** Save raw content to a filter definition file (``PUT /filters/{name}/raw``). */
|
||||
export async function updateFilterFile(
|
||||
name: string,
|
||||
req: ConfFileUpdateRequest
|
||||
): 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(
|
||||
req: ConfFileCreateRequest
|
||||
): 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> {
|
||||
@@ -277,6 +282,69 @@ export async function updateParsedFilter(
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
211
frontend/src/components/config/AssignFilterDialog.tsx
Normal file
211
frontend/src/components/config/AssignFilterDialog.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,16 @@ export interface ConfigListDetailProps<T extends { name: string }> {
|
||||
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<T extends { name: string }>({
|
||||
loading,
|
||||
error,
|
||||
children,
|
||||
listHeader,
|
||||
itemBadgeLabel,
|
||||
}: ConfigListDetailProps<T>): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
|
||||
@@ -212,6 +224,10 @@ export function ConfigListDetail<T extends { name: string }>({
|
||||
onKeyDown={handleKeyDown}
|
||||
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) */}
|
||||
<div
|
||||
style={{
|
||||
@@ -241,6 +257,11 @@ export function ConfigListDetail<T extends { name: string }>({
|
||||
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 = (
|
||||
<div
|
||||
@@ -280,7 +301,7 @@ export function ConfigListDetail<T extends { name: string }>({
|
||||
size="small"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{active ? "Active" : "Inactive"}
|
||||
{badgeText}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
216
frontend/src/components/config/CreateFilterDialog.tsx
Normal file
216
frontend/src/components/config/CreateFilterDialog.tsx
Normal 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/<name>.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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
* those referenced by at least one running jail. Right pane: structured form
|
||||
* editor plus a collapsible raw-config editor.
|
||||
* Left pane: filter names with Active/Inactive badges (active filters sorted
|
||||
* to the top). Active badge also shows which jails reference the filter.
|
||||
* 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 {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { DocumentAdd24Regular } from "@fluentui/react-icons";
|
||||
import { fetchFilterFile, fetchFilterFiles, updateFilterFile } from "../../api/config";
|
||||
import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config";
|
||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
||||
import { Add24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
|
||||
import { fetchFilterFile, fetchFilters, updateFilterFile } from "../../api/config";
|
||||
import type { ConfFileUpdateRequest, FilterConfig } from "../../types/config";
|
||||
import { AssignFilterDialog } from "./AssignFilterDialog";
|
||||
import { ConfigListDetail } from "./ConfigListDetail";
|
||||
import { CreateFilterDialog } from "./CreateFilterDialog";
|
||||
import { FilterForm } from "./FilterForm";
|
||||
import { RawConfigSection } from "./RawConfigSection";
|
||||
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.
|
||||
*/
|
||||
export function FiltersTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [filters, setFilters] = useState<FilterConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = 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);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFilters = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchFilterFiles()
|
||||
fetchFilters()
|
||||
.then((resp) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setFiles(resp.files);
|
||||
setFilters(resp.filters);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load filters",
|
||||
);
|
||||
setError(err instanceof Error ? err.message : "Failed to load filters");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return (): void => {
|
||||
ctrl.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchRaw = useCallback(
|
||||
async (name: string): Promise<string> => {
|
||||
const result = await fetchFilterFile(name);
|
||||
return result.content;
|
||||
useEffect(() => {
|
||||
loadFilters();
|
||||
return (): void => {
|
||||
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(
|
||||
async (name: string, content: string): Promise<void> => {
|
||||
const req: ConfFileUpdateRequest = { content };
|
||||
await updateFilterFile(name, req);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading || statusLoading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading filters…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
@@ -100,55 +222,49 @@ export function FiltersTab(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<DocumentAdd24Regular
|
||||
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No filter files found.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
const listHeader = (
|
||||
<Button
|
||||
appearance="outline"
|
||||
icon={<Add24Regular />}
|
||||
size="small"
|
||||
onClick={() => { setCreateOpen(true); }}
|
||||
>
|
||||
Create Filter
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.tabContent}>
|
||||
<>
|
||||
<ConfigListDetail
|
||||
items={files}
|
||||
isActive={(f) => activeFilters.has(f.name)}
|
||||
items={filters}
|
||||
isActive={(f) => f.active}
|
||||
itemBadgeLabel={filterBadgeLabel}
|
||||
selectedName={selectedName}
|
||||
onSelect={setSelectedName}
|
||||
loading={false}
|
||||
error={null}
|
||||
listHeader={listHeader}
|
||||
>
|
||||
{selectedName !== null && (
|
||||
<div>
|
||||
<FilterForm name={selectedName} />
|
||||
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||
<RawConfigSection
|
||||
fetchContent={() => fetchRaw(selectedName)}
|
||||
saveContent={(content) => saveRaw(selectedName, content)}
|
||||
label="Raw Filter Configuration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedFilter !== null && (
|
||||
<FilterDetail
|
||||
filter={selectedFilter}
|
||||
onAssignClick={() => { setAssignOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
</ConfigListDetail>
|
||||
</div>
|
||||
|
||||
<AssignFilterDialog
|
||||
filterName={selectedName}
|
||||
open={assignOpen}
|
||||
onClose={() => { setAssignOpen(false); }}
|
||||
onAssigned={handleAssigned}
|
||||
/>
|
||||
|
||||
<CreateFilterDialog
|
||||
open={createOpen}
|
||||
onClose={() => { setCreateOpen(false); }}
|
||||
onCreate={handleCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
frontend/src/components/config/__tests__/FiltersTab.test.tsx
Normal file
233
frontend/src/components/config/__tests__/FiltersTab.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,16 @@ export { ActionForm } from "./ActionForm";
|
||||
export type { ActionFormProps } from "./ActionForm";
|
||||
export { ActivateJailDialog } from "./ActivateJailDialog";
|
||||
export type { ActivateJailDialogProps } from "./ActivateJailDialog";
|
||||
export { AssignFilterDialog } from "./AssignFilterDialog";
|
||||
export type { AssignFilterDialogProps } from "./AssignFilterDialog";
|
||||
export { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator";
|
||||
export { ConfFilesTab } from "./ConfFilesTab";
|
||||
export type { ConfFilesTabProps } from "./ConfFilesTab";
|
||||
export { ConfigListDetail } from "./ConfigListDetail";
|
||||
export type { ConfigListDetailProps } from "./ConfigListDetail";
|
||||
export { CreateFilterDialog } from "./CreateFilterDialog";
|
||||
export type { CreateFilterDialogProps } from "./CreateFilterDialog";
|
||||
export { ExportTab } from "./ExportTab";
|
||||
export { FilterForm } from "./FilterForm";
|
||||
export type { FilterFormProps } from "./FilterForm";
|
||||
|
||||
@@ -460,3 +460,45 @@ export interface JailActivationResponse {
|
||||
/** Human-readable result message. */
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user