From a284d38f560b6e9187748e33d60108041bd8322c Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 13 Mar 2026 14:34:57 +0100 Subject: [PATCH] feat(frontend): redesign Jails, Filters, and Actions tabs to list/detail layout Replace Accordion-based config tabs with the new ConfigListDetail two-pane layout. Each tab now shows a searchable list with active/inactive badges (active items sorted first) on the left and a structured form editor with a collapsible raw-text export section on the right. --- frontend/src/components/config/ActionsTab.tsx | 96 +++++++--- frontend/src/components/config/FiltersTab.tsx | 97 +++++++--- frontend/src/components/config/JailsTab.tsx | 181 +++++++++++------- 3 files changed, 249 insertions(+), 125 deletions(-) diff --git a/frontend/src/components/config/ActionsTab.tsx b/frontend/src/components/config/ActionsTab.tsx index 62da3bf..580557e 100644 --- a/frontend/src/components/config/ActionsTab.tsx +++ b/frontend/src/components/config/ActionsTab.tsx @@ -1,16 +1,13 @@ /** - * ActionsTab — form-based accordion editor for action.d files. + * ActionsTab — list/detail layout for action.d file editing. * - * Shows one accordion item per action file. Expanding a panel lazily loads - * the parsed action config and renders an {@link ActionForm}. + * Left pane: action names with Active/Inactive badges. Active actions are + * those referenced by at least one running jail. Right pane: structured form + * editor plus a collapsible raw-config editor. */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { - Accordion, - AccordionHeader, - AccordionItem, - AccordionPanel, Button, MessageBar, MessageBarBody, @@ -20,9 +17,12 @@ import { tokens, } from "@fluentui/react-components"; import { DocumentAdd24Regular } from "@fluentui/react-icons"; -import { fetchActionFiles } from "../../api/config"; -import type { ConfFileEntry } from "../../types/config"; +import { fetchActionFile, fetchActionFiles, updateActionFile } from "../../api/config"; +import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config"; +import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { ActionForm } from "./ActionForm"; +import { ConfigListDetail } from "./ConfigListDetail"; +import { RawConfigSection } from "./RawConfigSection"; import { useConfigStyles } from "./configStyles"; /** @@ -35,27 +35,53 @@ export function ActionsTab(): React.JSX.Element { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [selectedName, setSelectedName] = useState(null); + const { activeActions, loading: statusLoading } = useConfigActiveStatus(); + const abortRef = useRef(null); useEffect(() => { - let cancelled = false; + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; setLoading(true); + fetchActionFiles() .then((resp) => { - if (!cancelled) { + if (!ctrl.signal.aborted) { setFiles(resp.files); setLoading(false); } }) .catch((err: unknown) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : "Failed to load actions"); + if (!ctrl.signal.aborted) { + setError( + err instanceof Error ? err.message : "Failed to load actions", + ); setLoading(false); } }); - return (): void => { cancelled = true; }; + return (): void => { + ctrl.abort(); + }; }, []); - if (loading) { + const fetchRaw = useCallback( + async (name: string): Promise => { + const result = await fetchActionFile(name); + return result.content; + }, + [], + ); + + const saveRaw = useCallback( + async (name: string, content: string): Promise => { + const req: ConfFileUpdateRequest = { content }; + await updateActionFile(name, req); + }, + [], + ); + + if (loading || statusLoading) { return ( {[0, 1, 2].map((i) => ( @@ -86,7 +112,12 @@ export function ActionsTab(): React.JSX.Element { Create a new action file in the Export tab. - @@ -95,16 +126,27 @@ export function ActionsTab(): React.JSX.Element { return (
- - {files.map((f) => ( - - {f.filename} - - - - - ))} - + activeActions.has(f.name)} + selectedName={selectedName} + onSelect={setSelectedName} + loading={false} + error={null} + > + {selectedName !== null && ( +
+ +
+ fetchRaw(selectedName)} + saveContent={(content) => saveRaw(selectedName, content)} + label="Raw Action Configuration" + /> +
+
+ )} +
); } diff --git a/frontend/src/components/config/FiltersTab.tsx b/frontend/src/components/config/FiltersTab.tsx index a94f2d1..27ae1d7 100644 --- a/frontend/src/components/config/FiltersTab.tsx +++ b/frontend/src/components/config/FiltersTab.tsx @@ -1,16 +1,13 @@ /** - * FiltersTab — form-based accordion editor for filter.d files. + * FiltersTab — list/detail layout for filter.d file editing. * - * Shows one accordion item per filter file. Expanding a panel lazily loads - * the parsed filter config and renders a {@link FilterForm}. + * 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. */ -import { useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { - Accordion, - AccordionHeader, - AccordionItem, - AccordionPanel, Button, MessageBar, MessageBarBody, @@ -20,10 +17,14 @@ import { tokens, } from "@fluentui/react-components"; import { DocumentAdd24Regular } from "@fluentui/react-icons"; -import { fetchFilterFiles } from "../../api/config"; -import type { ConfFileEntry } from "../../types/config"; +import { fetchFilterFile, fetchFilterFiles, updateFilterFile } from "../../api/config"; +import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config"; +import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; +import { ConfigListDetail } from "./ConfigListDetail"; import { FilterForm } from "./FilterForm"; +import { RawConfigSection } from "./RawConfigSection"; import { useConfigStyles } from "./configStyles"; +import { useEffect, useRef } from "react"; /** * Tab component for the form-based filter.d editor. @@ -35,27 +36,53 @@ export function FiltersTab(): React.JSX.Element { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [selectedName, setSelectedName] = useState(null); + const { activeFilters, loading: statusLoading } = useConfigActiveStatus(); + const abortRef = useRef(null); useEffect(() => { - let cancelled = false; + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; setLoading(true); + fetchFilterFiles() .then((resp) => { - if (!cancelled) { + if (!ctrl.signal.aborted) { setFiles(resp.files); setLoading(false); } }) .catch((err: unknown) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : "Failed to load filters"); + if (!ctrl.signal.aborted) { + setError( + err instanceof Error ? err.message : "Failed to load filters", + ); setLoading(false); } }); - return (): void => { cancelled = true; }; + return (): void => { + ctrl.abort(); + }; }, []); - if (loading) { + const fetchRaw = useCallback( + async (name: string): Promise => { + const result = await fetchFilterFile(name); + return result.content; + }, + [], + ); + + const saveRaw = useCallback( + async (name: string, content: string): Promise => { + const req: ConfFileUpdateRequest = { content }; + await updateFilterFile(name, req); + }, + [], + ); + + if (loading || statusLoading) { return ( {[0, 1, 2].map((i) => ( @@ -86,7 +113,12 @@ export function FiltersTab(): React.JSX.Element { Create a new filter file in the Export tab. - @@ -95,16 +127,27 @@ export function FiltersTab(): React.JSX.Element { return (
- - {files.map((f) => ( - - {f.filename} - - - - - ))} - + activeFilters.has(f.name)} + selectedName={selectedName} + onSelect={setSelectedName} + loading={false} + error={null} + > + {selectedName !== null && ( +
+ +
+ fetchRaw(selectedName)} + saveContent={(content) => saveRaw(selectedName, content)} + label="Raw Filter Configuration" + /> +
+
+ )} +
); } diff --git a/frontend/src/components/config/JailsTab.tsx b/frontend/src/components/config/JailsTab.tsx index a7bd888..277e06e 100644 --- a/frontend/src/components/config/JailsTab.tsx +++ b/frontend/src/components/config/JailsTab.tsx @@ -1,17 +1,13 @@ /** - * JailsTab and JailAccordionPanel — per-jail configuration editor. + * JailsTab — list/detail layout for per-jail configuration editing. * - * Displays all active jails in an accordion. Each panel exposes editable - * fields for ban time, find time, max retries, regex patterns, log paths, - * date pattern, DNS mode, prefix regex, and ban-time escalation. + * Left pane: jail names with Active/Inactive badges, sorted with active on + * top. Right pane: editable form for the selected jail plus a collapsible + * raw-config editor. */ import { useCallback, useMemo, useState } from "react"; import { - Accordion, - AccordionHeader, - AccordionItem, - AccordionPanel, Badge, Button, Field, @@ -25,42 +21,55 @@ import { Text, tokens, } from "@fluentui/react-components"; -import { ArrowClockwise24Regular, Dismiss24Regular, LockClosed24Regular } from "@fluentui/react-icons"; +import { + ArrowClockwise24Regular, + Dismiss24Regular, + LockClosed24Regular, +} from "@fluentui/react-icons"; import { ApiError } from "../../api/client"; import { addLogPath, deleteLogPath, + fetchJailConfigFileContent, + updateJailConfigFile, } from "../../api/config"; import type { AddLogPathRequest, + ConfFileUpdateRequest, JailConfig, JailConfigUpdate, } from "../../types/config"; import { useAutoSave } from "../../hooks/useAutoSave"; +import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { useJailConfigs } from "../../hooks/useConfig"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; +import { ConfigListDetail } from "./ConfigListDetail"; +import { RawConfigSection } from "./RawConfigSection"; import { RegexList } from "./RegexList"; import { useConfigStyles } from "./configStyles"; // --------------------------------------------------------------------------- -// JailAccordionPanel +// JailConfigDetail // --------------------------------------------------------------------------- -interface JailAccordionPanelProps { +interface JailConfigDetailProps { jail: JailConfig; onSave: (name: string, update: JailConfigUpdate) => Promise; } /** - * Editable configuration panel for a single fail2ban jail. + * Editable configuration form for a single fail2ban jail. + * + * Contains all config fields plus a collapsible raw-config editor at the + * bottom. Previously known as JailAccordionPanel. * * @param props - Component props. * @returns JSX element. */ -function JailAccordionPanel({ +function JailConfigDetail({ jail, onSave, -}: JailAccordionPanelProps): React.JSX.Element { +}: JailConfigDetailProps): React.JSX.Element { const styles = useConfigStyles(); const [banTime, setBanTime] = useState(String(jail.ban_time)); const [findTime, setFindTime] = useState(String(jail.find_time)); @@ -80,12 +89,22 @@ function JailAccordionPanel({ // Ban-time escalation state const esc0 = jail.bantime_escalation; const [escEnabled, setEscEnabled] = useState(esc0?.increment ?? false); - const [escFactor, setEscFactor] = useState(esc0?.factor != null ? String(esc0.factor) : ""); + const [escFactor, setEscFactor] = useState( + esc0?.factor != null ? String(esc0.factor) : "", + ); const [escFormula, setEscFormula] = useState(esc0?.formula ?? ""); - const [escMultipliers, setEscMultipliers] = useState(esc0?.multipliers ?? ""); - const [escMaxTime, setEscMaxTime] = useState(esc0?.max_time != null ? String(esc0.max_time) : ""); - const [escRndTime, setEscRndTime] = useState(esc0?.rnd_time != null ? String(esc0.rnd_time) : ""); - const [escOverallJails, setEscOverallJails] = useState(esc0?.overall_jails ?? false); + const [escMultipliers, setEscMultipliers] = useState( + esc0?.multipliers ?? "", + ); + const [escMaxTime, setEscMaxTime] = useState( + esc0?.max_time != null ? String(esc0.max_time) : "", + ); + const [escRndTime, setEscRndTime] = useState( + esc0?.rnd_time != null ? String(esc0.rnd_time) : "", + ); + const [escOverallJails, setEscOverallJails] = useState( + esc0?.overall_jails ?? false, + ); const handleDeleteLogPath = useCallback( async (path: string) => { @@ -166,6 +185,20 @@ function JailAccordionPanel({ const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = useAutoSave(autoSavePayload, saveCurrent); + // Raw config file fetch/save helpers — uses jail.d/.conf convention. + const fetchRaw = useCallback(async (): Promise => { + const result = await fetchJailConfigFileContent(`${jail.name}.conf`); + return result.content; + }, [jail.name]); + + const saveRaw = useCallback( + async (content: string): Promise => { + const req: ConfFileUpdateRequest = { content }; + await updateJailConfigFile(`${jail.name}.conf`, req); + }, + [jail.name], + ); + return (
{msg && ( @@ -409,6 +442,15 @@ function JailAccordionPanel({ onRetry={retrySave} />
+ + {/* Raw Configuration */} +
+ +
); } @@ -418,7 +460,8 @@ function JailAccordionPanel({ // --------------------------------------------------------------------------- /** - * Tab component showing all active fail2ban jails with editable configs. + * Tab component showing all fail2ban jails in a list/detail layout with + * editable configuration forms. * * @returns JSX element. */ @@ -426,6 +469,8 @@ export function JailsTab(): React.JSX.Element { const styles = useConfigStyles(); const { jails, loading, error, refresh, updateJail, reloadAll } = useJailConfigs(); + const { activeJails } = useConfigActiveStatus(); + const [selectedName, setSelectedName] = useState(null); const [reloading, setReloading] = useState(false); const [reloadMsg, setReloadMsg] = useState(null); @@ -436,9 +481,7 @@ export function JailsTab(): React.JSX.Element { await reloadAll(); setReloadMsg("fail2ban reloaded."); } catch (err: unknown) { - setReloadMsg( - err instanceof ApiError ? err.message : "Reload failed.", - ); + setReloadMsg(err instanceof ApiError ? err.message : "Reload failed."); } finally { setReloading(false); } @@ -453,15 +496,38 @@ export function JailsTab(): React.JSX.Element {
); } - if (error) + + if (error) { return ( {error} ); + } + + if (jails.length === 0) { + return ( +
+ + + No active jails found. + + + Ensure fail2ban is running and jails are configured. + +
+ ); + } + + const selectedJail: JailConfig | undefined = jails.find( + (j) => j.name === selectedName, + ); return ( -
+
{reloadMsg && ( - + {reloadMsg} )} - {jails.length === 0 && ( -
- - - No active jails found. - - - Ensure fail2ban is running and jails are configured. - -
- )} - - {jails.map((jail) => ( - - - {jail.name} -   - - ban: {jail.ban_time}s - - - retries: {jail.max_retry} - - - - - - - ))} - + +
+ activeJails.has(jail.name)} + selectedName={selectedName} + onSelect={setSelectedName} + loading={false} + error={null} + > + {selectedJail !== undefined ? ( + + ) : null} + +
); }