feature/ignore-self-toggle #1

Merged
lukas.pupkalipinski merged 97 commits from feature/ignore-self-toggle into main 2026-03-14 21:19:28 +01:00
3 changed files with 249 additions and 125 deletions
Showing only changes of commit a284d38f56 - Show all commits

View File

@@ -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 * Left pane: action names with Active/Inactive badges. Active actions are
* the parsed action config and renders an {@link ActionForm}. * 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 { import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button, Button,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
@@ -20,9 +17,12 @@ import {
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { DocumentAdd24Regular } from "@fluentui/react-icons"; import { DocumentAdd24Regular } from "@fluentui/react-icons";
import { fetchActionFiles } from "../../api/config"; import { fetchActionFile, fetchActionFiles, updateActionFile } from "../../api/config";
import type { ConfFileEntry } from "../../types/config"; import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
import { ActionForm } from "./ActionForm"; import { ActionForm } from "./ActionForm";
import { ConfigListDetail } from "./ConfigListDetail";
import { RawConfigSection } from "./RawConfigSection";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
/** /**
@@ -35,27 +35,53 @@ export function ActionsTab(): React.JSX.Element {
const [files, setFiles] = useState<ConfFileEntry[]>([]); 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 { activeActions, loading: statusLoading } = useConfigActiveStatus();
const abortRef = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true); setLoading(true);
fetchActionFiles() fetchActionFiles()
.then((resp) => { .then((resp) => {
if (!cancelled) { if (!ctrl.signal.aborted) {
setFiles(resp.files); setFiles(resp.files);
setLoading(false); setLoading(false);
} }
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!cancelled) { if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load actions"); setError(
err instanceof Error ? err.message : "Failed to load actions",
);
setLoading(false); setLoading(false);
} }
}); });
return (): void => { cancelled = true; }; return (): void => {
ctrl.abort();
};
}, []); }, []);
if (loading) { const fetchRaw = useCallback(
async (name: string): Promise<string> => {
const result = await fetchActionFile(name);
return result.content;
},
[],
);
const saveRaw = useCallback(
async (name: string, content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content };
await updateActionFile(name, req);
},
[],
);
if (loading || statusLoading) {
return ( return (
<Skeleton aria-label="Loading actions…"> <Skeleton aria-label="Loading actions…">
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
@@ -86,7 +112,12 @@ export function ActionsTab(): React.JSX.Element {
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}> <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Create a new action file in the Export tab. Create a new action file in the Export tab.
</Text> </Text>
<Button appearance="primary" onClick={() => { window.location.hash = "#export"; }}> <Button
appearance="primary"
onClick={() => {
window.location.hash = "#export";
}}
>
Go to Export Go to Export
</Button> </Button>
</div> </div>
@@ -95,16 +126,27 @@ export function ActionsTab(): React.JSX.Element {
return ( return (
<div className={styles.tabContent}> <div className={styles.tabContent}>
<Accordion collapsible> <ConfigListDetail
{files.map((f) => ( items={files}
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}> isActive={(f) => activeActions.has(f.name)}
<AccordionHeader>{f.filename}</AccordionHeader> selectedName={selectedName}
<AccordionPanel> onSelect={setSelectedName}
<ActionForm name={f.name} /> loading={false}
</AccordionPanel> error={null}
</AccordionItem> >
))} {selectedName !== null && (
</Accordion> <div>
<ActionForm name={selectedName} />
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={() => fetchRaw(selectedName)}
saveContent={(content) => saveRaw(selectedName, content)}
label="Raw Action Configuration"
/>
</div>
</div>
)}
</ConfigListDetail>
</div> </div>
); );
} }

View File

@@ -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 * Left pane: filter names with Active/Inactive badges. Active filters are
* the parsed filter config and renders a {@link FilterForm}. * 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 { import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button, Button,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
@@ -20,10 +17,14 @@ import {
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { DocumentAdd24Regular } from "@fluentui/react-icons"; import { DocumentAdd24Regular } from "@fluentui/react-icons";
import { fetchFilterFiles } from "../../api/config"; import { fetchFilterFile, fetchFilterFiles, updateFilterFile } from "../../api/config";
import type { ConfFileEntry } from "../../types/config"; import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
import { ConfigListDetail } from "./ConfigListDetail";
import { FilterForm } from "./FilterForm"; import { FilterForm } from "./FilterForm";
import { RawConfigSection } from "./RawConfigSection";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
import { useEffect, useRef } from "react";
/** /**
* Tab component for the form-based filter.d editor. * Tab component for the form-based filter.d editor.
@@ -35,27 +36,53 @@ export function FiltersTab(): React.JSX.Element {
const [files, setFiles] = useState<ConfFileEntry[]>([]); 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 { activeFilters, loading: statusLoading } = useConfigActiveStatus();
const abortRef = useRef<AbortController | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true); setLoading(true);
fetchFilterFiles() fetchFilterFiles()
.then((resp) => { .then((resp) => {
if (!cancelled) { if (!ctrl.signal.aborted) {
setFiles(resp.files); setFiles(resp.files);
setLoading(false); setLoading(false);
} }
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (!cancelled) { 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); setLoading(false);
} }
}); });
return (): void => { cancelled = true; }; return (): void => {
ctrl.abort();
};
}, []); }, []);
if (loading) { const fetchRaw = useCallback(
async (name: string): Promise<string> => {
const result = await fetchFilterFile(name);
return result.content;
},
[],
);
const saveRaw = useCallback(
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) => (
@@ -86,7 +113,12 @@ export function FiltersTab(): React.JSX.Element {
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}> <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Create a new filter file in the Export tab. Create a new filter file in the Export tab.
</Text> </Text>
<Button appearance="primary" onClick={() => { window.location.hash = "#export"; }}> <Button
appearance="primary"
onClick={() => {
window.location.hash = "#export";
}}
>
Go to Export Go to Export
</Button> </Button>
</div> </div>
@@ -95,16 +127,27 @@ export function FiltersTab(): React.JSX.Element {
return ( return (
<div className={styles.tabContent}> <div className={styles.tabContent}>
<Accordion collapsible> <ConfigListDetail
{files.map((f) => ( items={files}
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}> isActive={(f) => activeFilters.has(f.name)}
<AccordionHeader>{f.filename}</AccordionHeader> selectedName={selectedName}
<AccordionPanel> onSelect={setSelectedName}
<FilterForm name={f.name} /> loading={false}
</AccordionPanel> error={null}
</AccordionItem> >
))} {selectedName !== null && (
</Accordion> <div>
<FilterForm name={selectedName} />
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={() => fetchRaw(selectedName)}
saveContent={(content) => saveRaw(selectedName, content)}
label="Raw Filter Configuration"
/>
</div>
</div>
)}
</ConfigListDetail>
</div> </div>
); );
} }

View File

@@ -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 * Left pane: jail names with Active/Inactive badges, sorted with active on
* fields for ban time, find time, max retries, regex patterns, log paths, * top. Right pane: editable form for the selected jail plus a collapsible
* date pattern, DNS mode, prefix regex, and ban-time escalation. * raw-config editor.
*/ */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Badge, Badge,
Button, Button,
Field, Field,
@@ -25,42 +21,55 @@ import {
Text, Text,
tokens, tokens,
} from "@fluentui/react-components"; } 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 { ApiError } from "../../api/client";
import { import {
addLogPath, addLogPath,
deleteLogPath, deleteLogPath,
fetchJailConfigFileContent,
updateJailConfigFile,
} from "../../api/config"; } from "../../api/config";
import type { import type {
AddLogPathRequest, AddLogPathRequest,
ConfFileUpdateRequest,
JailConfig, JailConfig,
JailConfigUpdate, JailConfigUpdate,
} from "../../types/config"; } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave"; import { useAutoSave } from "../../hooks/useAutoSave";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
import { useJailConfigs } from "../../hooks/useConfig"; import { useJailConfigs } from "../../hooks/useConfig";
import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ConfigListDetail } from "./ConfigListDetail";
import { RawConfigSection } from "./RawConfigSection";
import { RegexList } from "./RegexList"; import { RegexList } from "./RegexList";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// JailAccordionPanel // JailConfigDetail
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface JailAccordionPanelProps { interface JailConfigDetailProps {
jail: JailConfig; jail: JailConfig;
onSave: (name: string, update: JailConfigUpdate) => Promise<void>; onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
} }
/** /**
* 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. * @param props - Component props.
* @returns JSX element. * @returns JSX element.
*/ */
function JailAccordionPanel({ function JailConfigDetail({
jail, jail,
onSave, onSave,
}: JailAccordionPanelProps): React.JSX.Element { }: JailConfigDetailProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const [banTime, setBanTime] = useState(String(jail.ban_time)); const [banTime, setBanTime] = useState(String(jail.ban_time));
const [findTime, setFindTime] = useState(String(jail.find_time)); const [findTime, setFindTime] = useState(String(jail.find_time));
@@ -80,12 +89,22 @@ function JailAccordionPanel({
// Ban-time escalation state // Ban-time escalation state
const esc0 = jail.bantime_escalation; const esc0 = jail.bantime_escalation;
const [escEnabled, setEscEnabled] = useState(esc0?.increment ?? false); 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 [escFormula, setEscFormula] = useState(esc0?.formula ?? "");
const [escMultipliers, setEscMultipliers] = useState(esc0?.multipliers ?? ""); const [escMultipliers, setEscMultipliers] = useState(
const [escMaxTime, setEscMaxTime] = useState(esc0?.max_time != null ? String(esc0.max_time) : ""); esc0?.multipliers ?? "",
const [escRndTime, setEscRndTime] = useState(esc0?.rnd_time != null ? String(esc0.rnd_time) : ""); );
const [escOverallJails, setEscOverallJails] = useState(esc0?.overall_jails ?? false); 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( const handleDeleteLogPath = useCallback(
async (path: string) => { async (path: string) => {
@@ -166,6 +185,20 @@ function JailAccordionPanel({
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, saveCurrent); useAutoSave(autoSavePayload, saveCurrent);
// Raw config file fetch/save helpers — uses jail.d/<name>.conf convention.
const fetchRaw = useCallback(async (): Promise<string> => {
const result = await fetchJailConfigFileContent(`${jail.name}.conf`);
return result.content;
}, [jail.name]);
const saveRaw = useCallback(
async (content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content };
await updateJailConfigFile(`${jail.name}.conf`, req);
},
[jail.name],
);
return ( return (
<div> <div>
{msg && ( {msg && (
@@ -409,6 +442,15 @@ function JailAccordionPanel({
onRetry={retrySave} onRetry={retrySave}
/> />
</div> </div>
{/* Raw Configuration */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
label="Raw Jail Configuration"
/>
</div>
</div> </div>
); );
} }
@@ -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. * @returns JSX element.
*/ */
@@ -426,6 +469,8 @@ export function JailsTab(): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail, reloadAll } = const { jails, loading, error, refresh, updateJail, reloadAll } =
useJailConfigs(); useJailConfigs();
const { activeJails } = useConfigActiveStatus();
const [selectedName, setSelectedName] = useState<string | null>(null);
const [reloading, setReloading] = useState(false); const [reloading, setReloading] = useState(false);
const [reloadMsg, setReloadMsg] = useState<string | null>(null); const [reloadMsg, setReloadMsg] = useState<string | null>(null);
@@ -436,9 +481,7 @@ export function JailsTab(): React.JSX.Element {
await reloadAll(); await reloadAll();
setReloadMsg("fail2ban reloaded."); setReloadMsg("fail2ban reloaded.");
} catch (err: unknown) { } catch (err: unknown) {
setReloadMsg( setReloadMsg(err instanceof ApiError ? err.message : "Reload failed.");
err instanceof ApiError ? err.message : "Reload failed.",
);
} finally { } finally {
setReloading(false); setReloading(false);
} }
@@ -453,15 +496,38 @@ export function JailsTab(): React.JSX.Element {
</Skeleton> </Skeleton>
); );
} }
if (error)
if (error) {
return ( return (
<MessageBar intent="error"> <MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody> <MessageBarBody>{error}</MessageBarBody>
</MessageBar> </MessageBar>
); );
}
if (jails.length === 0) {
return (
<div className={styles.emptyState}>
<LockClosed24Regular
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No active jails found.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Ensure fail2ban is running and jails are configured.
</Text>
</div>
);
}
const selectedJail: JailConfig | undefined = jails.find(
(j) => j.name === selectedName,
);
return ( return (
<div> <div className={styles.tabContent}>
<div className={styles.buttonRow}> <div className={styles.buttonRow}>
<Button <Button
appearance="secondary" appearance="secondary"
@@ -480,55 +546,28 @@ export function JailsTab(): React.JSX.Element {
</Button> </Button>
</div> </div>
{reloadMsg && ( {reloadMsg && (
<MessageBar style={{ marginTop: tokens.spacingVerticalS }} intent="info"> <MessageBar
style={{ marginTop: tokens.spacingVerticalS }}
intent="info"
>
<MessageBarBody>{reloadMsg}</MessageBarBody> <MessageBarBody>{reloadMsg}</MessageBarBody>
</MessageBar> </MessageBar>
)} )}
{jails.length === 0 && (
<div className={styles.emptyState}> <div style={{ marginTop: tokens.spacingVerticalM }}>
<LockClosed24Regular <ConfigListDetail
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }} items={jails}
aria-hidden isActive={(jail) => activeJails.has(jail.name)}
/> selectedName={selectedName}
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}> onSelect={setSelectedName}
No active jails found. loading={false}
</Text> error={null}
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}> >
Ensure fail2ban is running and jails are configured. {selectedJail !== undefined ? (
</Text> <JailConfigDetail jail={selectedJail} onSave={updateJail} />
) : null}
</ConfigListDetail>
</div> </div>
)}
<Accordion
multiple
collapsible
style={{ marginTop: tokens.spacingVerticalM }}
>
{jails.map((jail) => (
<AccordionItem key={jail.name} value={jail.name} className={styles.accordionItem}>
<AccordionHeader>
<Text weight="semibold">{jail.name}</Text>
&nbsp;
<Badge
appearance="tint"
color="informative"
style={{ marginLeft: tokens.spacingHorizontalS }}
>
ban: {jail.ban_time}s
</Badge>
<Badge
appearance="tint"
color="subtle"
style={{ marginLeft: tokens.spacingHorizontalXS }}
>
retries: {jail.max_retry}
</Badge>
</AccordionHeader>
<AccordionPanel>
<JailAccordionPanel jail={jail} onSave={updateJail} />
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</div> </div>
); );
} }