refactor(frontend): decompose ConfigPage into dedicated config components
- Extract tab components: JailsTab, ActionsTab, FiltersTab, JailFilesTab, GlobalTab, ServerTab, ConfFilesTab, RegexTesterTab, MapTab, ExportTab - Add form components: JailFileForm, ActionForm, FilterForm - Add AutoSaveIndicator, RegexList, configStyles, and barrel index - ConfigPage now composes these components; greatly reduces file size - Add tests: ConfigPage.test.tsx, useAutoSave.test.ts
This commit is contained in:
328
frontend/src/components/config/ActionForm.tsx
Normal file
328
frontend/src/components/config/ActionForm.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* ActionForm — structured form editor for a single ``action.d/*.conf`` file.
|
||||
*
|
||||
* Displays parsed fields grouped into collapsible sections:
|
||||
* - Includes (before / after)
|
||||
* - Lifecycle commands (actionstart, actionstop, actioncheck, actionban,
|
||||
* actionunban, actionflush)
|
||||
* - Definition variables (extra [Definition] key-value pairs)
|
||||
* - Init variables ([Init] section key-value pairs)
|
||||
*
|
||||
* Provides a Save button and shows saving/error state.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@fluentui/react-components";
|
||||
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
|
||||
import type { ActionConfig, ActionConfigUpdate } from "../../types/config";
|
||||
import { useActionConfig } from "../../hooks/useActionConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Editable key-value table for definition_vars / init_vars. */
|
||||
interface KVEditorProps {
|
||||
entries: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const rows = Object.entries(entries);
|
||||
|
||||
const handleKeyChange = (oldKey: string, newKey: string): void => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
next[k === oldKey ? newKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleValueChange = (key: string, value: string): void => {
|
||||
onChange({ ...entries, [key]: value });
|
||||
};
|
||||
|
||||
const handleDelete = (key: string): void => {
|
||||
const { [key]: _removed, ...rest } = entries;
|
||||
onChange(rest);
|
||||
};
|
||||
|
||||
const handleAdd = (): void => {
|
||||
let newKey = "new_var";
|
||||
let n = 1;
|
||||
while (newKey in entries) {
|
||||
newKey = `new_var_${String(n)}`;
|
||||
n++;
|
||||
}
|
||||
onChange({ ...entries, [newKey]: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key} className={styles.fieldRow}>
|
||||
<Input
|
||||
value={key}
|
||||
size="small"
|
||||
style={{ width: 160, fontFamily: "monospace" }}
|
||||
aria-label={`Variable name: ${key}`}
|
||||
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
|
||||
/>
|
||||
<Textarea
|
||||
value={value}
|
||||
size="small"
|
||||
style={{ flex: 1, fontFamily: "monospace" }}
|
||||
rows={value.includes("\n") ? 3 : 1}
|
||||
aria-label={`Value for ${key}`}
|
||||
onChange={(_e, d) => { handleValueChange(key, d.value); }}
|
||||
/>
|
||||
<Button
|
||||
icon={<Delete24Regular />}
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => { handleDelete(key); }}
|
||||
aria-label={`Delete variable ${key}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
icon={<Add24Regular />}
|
||||
size="small"
|
||||
appearance="outline"
|
||||
onClick={handleAdd}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A Textarea field for a single lifecycle command. */
|
||||
interface CommandFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function CommandField({ label, value, onChange }: CommandFieldProps): React.JSX.Element {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(_e, d) => { onChange(d.value); }}
|
||||
rows={value.split("\n").length + 1}
|
||||
style={{ fontFamily: "monospace", width: "100%" }}
|
||||
placeholder={`${label} command(s)`}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActionFormEditor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActionFormEditorProps {
|
||||
config: ActionConfig;
|
||||
onSave: (update: ActionConfigUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
function ActionFormEditor({
|
||||
config,
|
||||
onSave,
|
||||
}: ActionFormEditorProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
|
||||
const [before, setBefore] = useState(config.before ?? "");
|
||||
const [after, setAfter] = useState(config.after ?? "");
|
||||
const [actionstart, setActionstart] = useState(config.actionstart ?? "");
|
||||
const [actionstop, setActionstop] = useState(config.actionstop ?? "");
|
||||
const [actioncheck, setActioncheck] = useState(config.actioncheck ?? "");
|
||||
const [actionban, setActionban] = useState(config.actionban ?? "");
|
||||
const [actionunban, setActionunban] = useState(config.actionunban ?? "");
|
||||
const [actionflush, setActionflush] = useState(config.actionflush ?? "");
|
||||
const [definitionVars, setDefinitionVars] = useState<Record<string, string>>(
|
||||
config.definition_vars
|
||||
);
|
||||
const [initVars, setInitVars] = useState<Record<string, string>>(config.init_vars);
|
||||
|
||||
// Reset draft when config reloads.
|
||||
useEffect(() => {
|
||||
setBefore(config.before ?? "");
|
||||
setAfter(config.after ?? "");
|
||||
setActionstart(config.actionstart ?? "");
|
||||
setActionstop(config.actionstop ?? "");
|
||||
setActioncheck(config.actioncheck ?? "");
|
||||
setActionban(config.actionban ?? "");
|
||||
setActionunban(config.actionunban ?? "");
|
||||
setActionflush(config.actionflush ?? "");
|
||||
setDefinitionVars(config.definition_vars);
|
||||
setInitVars(config.init_vars);
|
||||
}, [config]);
|
||||
|
||||
const autoSavePayload = useMemo<ActionConfigUpdate>(() => ({
|
||||
before: before.trim() || null,
|
||||
after: after.trim() || null,
|
||||
actionstart: actionstart.trim() || null,
|
||||
actionstop: actionstop.trim() || null,
|
||||
actioncheck: actioncheck.trim() || null,
|
||||
actionban: actionban.trim() || null,
|
||||
actionunban: actionunban.trim() || null,
|
||||
actionflush: actionflush.trim() || null,
|
||||
definition_vars: definitionVars,
|
||||
init_vars: initVars,
|
||||
}), [
|
||||
after, actionban, actioncheck, actionflush, actionstart,
|
||||
actionstop, actionunban, before, definitionVars, initVars,
|
||||
]);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(autoSavePayload, onSave);
|
||||
|
||||
const defVarCount = Object.keys(definitionVars).length;
|
||||
const initVarCount = Object.keys(initVars).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
|
||||
<Text size={500} weight="semibold">
|
||||
{config.filename}
|
||||
</Text>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion multiple collapsible defaultOpenItems={["lifecycle"]}>
|
||||
{/* Includes */}
|
||||
<AccordionItem value="includes" className={styles.accordionItem}>
|
||||
<AccordionHeader>Includes</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<Field label="before">
|
||||
<Input
|
||||
value={before}
|
||||
onChange={(_e, d) => { setBefore(d.value); }}
|
||||
placeholder="e.g. iptables-common.conf"
|
||||
className={styles.codeInput}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="after">
|
||||
<Input
|
||||
value={after}
|
||||
onChange={(_e, d) => { setAfter(d.value); }}
|
||||
className={styles.codeInput}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Lifecycle commands */}
|
||||
<AccordionItem value="lifecycle" className={styles.accordionItem}>
|
||||
<AccordionHeader>Lifecycle commands</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<CommandField label="actionstart" value={actionstart} onChange={setActionstart} />
|
||||
<CommandField label="actionstop" value={actionstop} onChange={setActionstop} />
|
||||
<CommandField label="actioncheck" value={actioncheck} onChange={setActioncheck} />
|
||||
<CommandField label="actionban" value={actionban} onChange={setActionban} />
|
||||
<CommandField label="actionunban" value={actionunban} onChange={setActionunban} />
|
||||
<CommandField label="actionflush" value={actionflush} onChange={setActionflush} />
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Definition variables */}
|
||||
<AccordionItem value="definition_vars" className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
{`Definition variables (${String(defVarCount)})`}
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<KVEditor entries={definitionVars} onChange={setDefinitionVars} />
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Init variables */}
|
||||
<AccordionItem value="init_vars" className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
{`Init variables (${String(initVarCount)})`}
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<KVEditor entries={initVars} onChange={setInitVars} />
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActionForm (public export)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ActionFormProps {
|
||||
/** Action base name (e.g. ``"iptables"``). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and renders the structured form editor for one action.
|
||||
*/
|
||||
export function ActionForm({ name }: ActionFormProps): React.JSX.Element {
|
||||
const { config, loading, error, save } = useActionConfig(name);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label={`Loading ${name}…`}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
<SkeletonItem size={72} style={{ marginBottom: 8 }} />
|
||||
<SkeletonItem size={72} />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null || config === null) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error ?? "Failed to load action config"}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionFormEditor
|
||||
config={config}
|
||||
onSave={save}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
111
frontend/src/components/config/ActionsTab.tsx
Normal file
111
frontend/src/components/config/ActionsTab.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ActionsTab — form-based accordion editor for action.d files.
|
||||
*
|
||||
* Shows one accordion item per action file. Expanding a panel lazily loads
|
||||
* the parsed action config and renders an {@link ActionForm}.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { DocumentAdd24Regular } from "@fluentui/react-icons";
|
||||
import { fetchActionFiles } from "../../api/config";
|
||||
import type { ConfFileEntry } from "../../types/config";
|
||||
import { ActionForm } from "./ActionForm";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/**
|
||||
* Tab component for the form-based action.d editor.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function ActionsTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchActionFiles()
|
||||
.then((resp) => {
|
||||
if (!cancelled) {
|
||||
setFiles(resp.files);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load actions");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return (): void => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading actions…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
||||
))}
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
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 action files found.
|
||||
</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Create a new action file in the Export tab.
|
||||
</Text>
|
||||
<Button appearance="primary" onClick={() => { window.location.hash = "#export"; }}>
|
||||
Go to Export
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tabContent}>
|
||||
<Accordion collapsible>
|
||||
{files.map((f) => (
|
||||
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}>
|
||||
<AccordionHeader>{f.filename}</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<ActionForm name={f.name} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
124
frontend/src/components/config/AutoSaveIndicator.tsx
Normal file
124
frontend/src/components/config/AutoSaveIndicator.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* AutoSaveIndicator — small status chip showing auto-save state.
|
||||
*
|
||||
* Displays nothing while idle, a spinner while saving, a transient
|
||||
* "Saved" checkmark on success, and an error message with retry on failure.
|
||||
*
|
||||
* Always renders an ``aria-live`` region so screen readers receive
|
||||
* status change announcements even when the visible badge is absent.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Spinner,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { Checkmark16Regular } from "@fluentui/react-icons";
|
||||
|
||||
/** Current save state driven by the parent (typically useAutoSave). */
|
||||
export type AutoSaveStatus = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
export interface AutoSaveIndicatorProps {
|
||||
/** Current save status. */
|
||||
status: AutoSaveStatus;
|
||||
/** Error text shown when status is "error". */
|
||||
errorText?: string | null;
|
||||
/** Called when the user clicks the retry link. */
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/** Fade-out delay after "saved" status in milliseconds. */
|
||||
const SAVED_FADE_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Compact inline indicator for auto-save state.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function AutoSaveIndicator({
|
||||
status,
|
||||
errorText,
|
||||
onRetry,
|
||||
}: AutoSaveIndicatorProps): React.JSX.Element {
|
||||
const [fadingOut, setFadingOut] = useState(false);
|
||||
|
||||
// Trigger the fade-out transition 2 s after the saved state is reached.
|
||||
useEffect((): (() => void) | undefined => {
|
||||
if (status !== "saved") {
|
||||
setFadingOut(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
setFadingOut(true);
|
||||
}, SAVED_FADE_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [status]);
|
||||
|
||||
// Always render the aria-live region so screen readers track changes.
|
||||
return (
|
||||
<span
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
minWidth: 80,
|
||||
}}
|
||||
>
|
||||
{status === "saving" && (
|
||||
<>
|
||||
<Spinner size="extra-tiny" />
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground2 }}>
|
||||
Saving…
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "saved" && (
|
||||
<span
|
||||
style={{
|
||||
opacity: fadingOut ? 0 : 1,
|
||||
transform: fadingOut ? "scale(0.95)" : "scale(1)",
|
||||
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
|
||||
animationName: fadingOut ? undefined : "fadeInScale",
|
||||
animationDuration: tokens.durationFast,
|
||||
animationTimingFunction: tokens.curveDecelerateMid,
|
||||
animationFillMode: "both",
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
appearance="tint"
|
||||
color="success"
|
||||
icon={<Checkmark16Regular />}
|
||||
size="small"
|
||||
>
|
||||
Saved
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<>
|
||||
<Text
|
||||
size={200}
|
||||
style={{ color: tokens.colorPaletteRedForeground3 }}
|
||||
>
|
||||
{errorText ?? "Save failed."}
|
||||
</Text>
|
||||
{onRetry && (
|
||||
<Button appearance="transparent" size="small" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
319
frontend/src/components/config/ConfFilesTab.tsx
Normal file
319
frontend/src/components/config/ConfFilesTab.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* ConfFilesTab — generic editable file list for filter.d / action.d.
|
||||
*
|
||||
* Renders an accordion of conf files, each with a read/edit textarea and
|
||||
* individual save button. Accepts fetch/create/update callbacks so it can
|
||||
* be reused for both filter and action files.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
Textarea,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowClockwise24Regular, Save24Regular } from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { ConfFileEntry } from "../../types/config";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
export interface ConfFilesTabProps {
|
||||
/** Human-readable label, e.g. "Filter" or "Action". */
|
||||
label: string;
|
||||
/** Fetches the list of available files. */
|
||||
fetchList: () => Promise<{ files: ConfFileEntry[]; total: number }>;
|
||||
/** Fetches the content of a single file by name (without extension). */
|
||||
fetchFile: (name: string) => Promise<{
|
||||
name: string;
|
||||
filename: string;
|
||||
content: string;
|
||||
}>;
|
||||
/** Persists updated content for the given file name. */
|
||||
updateFile: (name: string, req: { content: string }) => Promise<void>;
|
||||
/** Creates a new file with the given name and content. */
|
||||
createFile: (req: {
|
||||
name: string;
|
||||
content: string;
|
||||
}) => Promise<{ name: string; filename: string; content: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic tab component for editing raw conf files (filter.d or action.d).
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function ConfFilesTab({
|
||||
label,
|
||||
fetchList,
|
||||
fetchFile,
|
||||
updateFile,
|
||||
createFile,
|
||||
}: ConfFilesTabProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openItems, setOpenItems] = useState<string[]>([]);
|
||||
const [contents, setContents] = useState<Record<string, string>>({});
|
||||
const [editedContents, setEditedContents] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newContent, setNewContent] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetchList();
|
||||
setFiles(resp.files);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: `Failed to load ${label.toLowerCase()} files.`,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchList, label]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleAccordionToggle = useCallback(
|
||||
(
|
||||
_e: React.SyntheticEvent,
|
||||
data: { openItems: (string | number)[] },
|
||||
) => {
|
||||
const next = data.openItems as string[];
|
||||
const newlyOpened = next.filter((v) => !openItems.includes(v));
|
||||
setOpenItems(next);
|
||||
for (const name of newlyOpened) {
|
||||
if (!Object.prototype.hasOwnProperty.call(contents, name)) {
|
||||
void fetchFile(name)
|
||||
.then((c) => {
|
||||
setContents((prev) => ({ ...prev, [name]: c.content }));
|
||||
setEditedContents((prev) => ({ ...prev, [name]: c.content }));
|
||||
})
|
||||
.catch(() => {
|
||||
setContents((prev) => ({
|
||||
...prev,
|
||||
[name]: "(failed to load)",
|
||||
}));
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[name]: "(failed to load)",
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[openItems, contents, fetchFile],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (name: string) => {
|
||||
setSaving(name);
|
||||
setMsg(null);
|
||||
try {
|
||||
const content = editedContents[name] ?? contents[name] ?? "";
|
||||
await updateFile(name, { content });
|
||||
setContents((prev) => ({ ...prev, [name]: content }));
|
||||
setMsg({ text: `${name} saved.`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Save failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
},
|
||||
[editedContents, contents, updateFile],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setCreating(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const created = await createFile({ name, content: newContent });
|
||||
setFiles((prev) => [
|
||||
...prev,
|
||||
{ name: created.name, filename: created.filename },
|
||||
]);
|
||||
setContents((prev) => ({ ...prev, [created.name]: created.content }));
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[created.name]: created.content,
|
||||
}));
|
||||
setNewName("");
|
||||
setNewContent("");
|
||||
setMsg({ text: `${created.filename} created.`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Create failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newName, newContent, createFile]);
|
||||
|
||||
if (loading) return <Spinner label={`Loading ${label.toLowerCase()} files…`} />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && (
|
||||
<MessageBar
|
||||
intent={msg.ok ? "success" : "error"}
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={() => void loadFiles()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{files.length === 0 && (
|
||||
<Text
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
No {label.toLowerCase()} files found.
|
||||
</Text>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
openItems={openItems}
|
||||
onToggle={handleAccordionToggle}
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<AccordionItem key={file.name} value={file.name}>
|
||||
<AccordionHeader>
|
||||
<Text className={styles.codeFont}>{file.filename}</Text>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
{openItems.includes(file.name) &&
|
||||
(contents[file.name] === undefined ? (
|
||||
<Spinner size="tiny" label="Loading…" />
|
||||
) : (
|
||||
<div>
|
||||
<Textarea
|
||||
value={editedContents[file.name] ?? ""}
|
||||
rows={20}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onChange={(_e, d) => {
|
||||
setEditedContents((prev) => ({
|
||||
...prev,
|
||||
[file.name]: d.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Save24Regular />}
|
||||
disabled={saving === file.name}
|
||||
onClick={() => void handleSave(file.name)}
|
||||
>
|
||||
{saving === file.name ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
{/* Create new file */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: tokens.spacingVerticalXL,
|
||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
paddingTop: tokens.spacingVerticalM,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="h3"
|
||||
size={400}
|
||||
weight="semibold"
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
New {label} File
|
||||
</Text>
|
||||
<Field label="Name (without .conf extension)">
|
||||
<Input
|
||||
value={newName}
|
||||
placeholder={`e.g. my-${label.toLowerCase()}`}
|
||||
className={styles.codeFont}
|
||||
onChange={(_e, d) => {
|
||||
setNewName(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Content" style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Textarea
|
||||
value={newContent}
|
||||
rows={10}
|
||||
placeholder={`[Definition]\n# …`}
|
||||
style={{
|
||||
width: "100%",
|
||||
resize: "vertical",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
onChange={(_e, d) => {
|
||||
setNewContent(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={creating || !newName.trim()}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? "Creating…" : `Create ${label} File`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/config/ExportTab.tsx
Normal file
88
frontend/src/components/config/ExportTab.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* ExportTab — raw config file editing escape hatch.
|
||||
*
|
||||
* Three sub-tabs: "Jail Files", "Filters", "Actions". Each sub-tab gives
|
||||
* direct access to the raw file content via textarea + per-file Save button,
|
||||
* identical to the behaviour that was previously in the dedicated tabs.
|
||||
*
|
||||
* This tab provides an advanced interface for users who need to edit config
|
||||
* files directly without the structured form UI.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tab, TabList, Text, tokens } from "@fluentui/react-components";
|
||||
import {
|
||||
fetchActionFile,
|
||||
fetchActionFiles,
|
||||
fetchFilterFile,
|
||||
fetchFilterFiles,
|
||||
createActionFile,
|
||||
createFilterFile,
|
||||
updateActionFile,
|
||||
updateFilterFile,
|
||||
} from "../../api/config";
|
||||
import { JailFilesTab } from "./JailFilesTab";
|
||||
import { ConfFilesTab } from "./ConfFilesTab";
|
||||
|
||||
type ExportSubTab = "jailfiles" | "filters" | "actions";
|
||||
|
||||
/**
|
||||
* Export tab containing raw-file editors for jail, filter, and action files.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function ExportTab(): React.JSX.Element {
|
||||
const [sub, setSub] = useState<ExportSubTab>("jailfiles");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
style={{
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontStyle: "italic",
|
||||
marginBottom: tokens.spacingVerticalM,
|
||||
}}
|
||||
>
|
||||
Direct raw-file editing — for advanced users. Changes take effect on
|
||||
next fail2ban reload.
|
||||
</Text>
|
||||
|
||||
<TabList
|
||||
appearance="subtle"
|
||||
selectedValue={sub}
|
||||
onTabSelect={(_e, d) => {
|
||||
setSub(d.value as ExportSubTab);
|
||||
}}
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
<Tab value="jailfiles">Jail Files</Tab>
|
||||
<Tab value="filters">Filters</Tab>
|
||||
<Tab value="actions">Actions</Tab>
|
||||
</TabList>
|
||||
|
||||
{sub === "jailfiles" && <JailFilesTab />}
|
||||
|
||||
{sub === "filters" && (
|
||||
<ConfFilesTab
|
||||
label="Filter"
|
||||
fetchList={fetchFilterFiles}
|
||||
fetchFile={fetchFilterFile}
|
||||
updateFile={updateFilterFile}
|
||||
createFile={createFilterFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sub === "actions" && (
|
||||
<ConfFilesTab
|
||||
label="Action"
|
||||
fetchList={fetchActionFiles}
|
||||
fetchFile={fetchActionFile}
|
||||
updateFile={updateActionFile}
|
||||
createFile={createActionFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
332
frontend/src/components/config/FilterForm.tsx
Normal file
332
frontend/src/components/config/FilterForm.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* FilterForm — structured form editor for a single ``filter.d/*.conf`` file.
|
||||
*
|
||||
* Displays parsed fields grouped into collapsible sections:
|
||||
* - Includes (before / after)
|
||||
* - Variables ([DEFAULT] key-value pairs)
|
||||
* - Definition (prefregex, failregex, ignoreregex, optional advanced fields)
|
||||
*
|
||||
* Provides a Save button and shows saving/error state.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@fluentui/react-components";
|
||||
import { Add24Regular, Delete24Regular } from "@fluentui/react-icons";
|
||||
import type { FilterConfig, FilterConfigUpdate } from "../../types/config";
|
||||
import { useFilterConfig } from "../../hooks/useFilterConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { RegexList } from "./RegexList";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Simple editable key-value table for [DEFAULT] variables. */
|
||||
interface KVEditorProps {
|
||||
entries: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const rows = Object.entries(entries);
|
||||
|
||||
const handleKeyChange = (oldKey: string, newKey: string): void => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
next[k === oldKey ? newKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleValueChange = (key: string, value: string): void => {
|
||||
onChange({ ...entries, [key]: value });
|
||||
};
|
||||
|
||||
const handleDelete = (key: string): void => {
|
||||
const { [key]: _removed, ...rest } = entries;
|
||||
onChange(rest);
|
||||
};
|
||||
|
||||
const handleAdd = (): void => {
|
||||
let newKey = "new_var";
|
||||
let n = 1;
|
||||
while (newKey in entries) {
|
||||
newKey = `new_var_${String(n)}`;
|
||||
n++;
|
||||
}
|
||||
onChange({ ...entries, [newKey]: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key} className={styles.fieldRow}>
|
||||
<Input
|
||||
value={key}
|
||||
size="small"
|
||||
style={{ width: 160, fontFamily: "monospace" }}
|
||||
aria-label={`Variable name: ${key}`}
|
||||
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
size="small"
|
||||
style={{ flex: 1, fontFamily: "monospace" }}
|
||||
aria-label={`Value for ${key}`}
|
||||
onChange={(_e, d) => { handleValueChange(key, d.value); }}
|
||||
/>
|
||||
<Button
|
||||
icon={<Delete24Regular />}
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => { handleDelete(key); }}
|
||||
aria-label={`Delete variable ${key}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
icon={<Add24Regular />}
|
||||
size="small"
|
||||
appearance="outline"
|
||||
onClick={handleAdd}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterFormEditor — rendered once config is loaded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FilterFormEditorProps {
|
||||
config: FilterConfig;
|
||||
onSave: (update: FilterConfigUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
function FilterFormEditor({
|
||||
config,
|
||||
onSave,
|
||||
}: FilterFormEditorProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
|
||||
// Local draft state — initialised from the loaded config.
|
||||
const [before, setBefore] = useState(config.before ?? "");
|
||||
const [after, setAfter] = useState(config.after ?? "");
|
||||
const [variables, setVariables] = useState<Record<string, string>>(config.variables);
|
||||
const [prefregex, setPrefregex] = useState(config.prefregex ?? "");
|
||||
const [failregex, setFailregex] = useState<string[]>(config.failregex);
|
||||
const [ignoreregex, setIgnoreregex] = useState<string[]>(config.ignoreregex);
|
||||
const [maxlines, setMaxlines] = useState(
|
||||
config.maxlines !== null ? String(config.maxlines) : ""
|
||||
);
|
||||
const [datepattern, setDatepattern] = useState(config.datepattern ?? "");
|
||||
const [journalmatch, setJournalmatch] = useState(config.journalmatch ?? "");
|
||||
|
||||
// Reset draft whenever a freshly-loaded config arrives.
|
||||
useEffect(() => {
|
||||
setBefore(config.before ?? "");
|
||||
setAfter(config.after ?? "");
|
||||
setVariables(config.variables);
|
||||
setPrefregex(config.prefregex ?? "");
|
||||
setFailregex(config.failregex);
|
||||
setIgnoreregex(config.ignoreregex);
|
||||
setMaxlines(config.maxlines !== null ? String(config.maxlines) : "");
|
||||
setDatepattern(config.datepattern ?? "");
|
||||
setJournalmatch(config.journalmatch ?? "");
|
||||
}, [config]);
|
||||
|
||||
const autoSavePayload = useMemo<FilterConfigUpdate>(() => {
|
||||
const parsedMax = maxlines.trim() !== "" ? parseInt(maxlines, 10) : null;
|
||||
return {
|
||||
before: before.trim() || null,
|
||||
after: after.trim() || null,
|
||||
variables,
|
||||
prefregex: prefregex.trim() || null,
|
||||
failregex,
|
||||
ignoreregex,
|
||||
maxlines: parsedMax !== null && !isNaN(parsedMax) ? parsedMax : null,
|
||||
datepattern: datepattern.trim() || null,
|
||||
journalmatch: journalmatch.trim() || null,
|
||||
};
|
||||
}, [after, before, datepattern, failregex, ignoreregex, journalmatch, maxlines, prefregex, variables]);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(autoSavePayload, onSave);
|
||||
|
||||
const hasIncludes = config.before !== null || config.after !== null;
|
||||
const varCount = Object.keys(variables).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
|
||||
<Text size={500} weight="semibold">
|
||||
{config.filename}
|
||||
</Text>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion multiple collapsible defaultOpenItems={["definition"]}>
|
||||
{/* Includes */}
|
||||
<AccordionItem value="includes" className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
{`Includes${hasIncludes ? "" : " (none)"}`}
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<Field label="before">
|
||||
<Input
|
||||
value={before}
|
||||
onChange={(_e, d) => { setBefore(d.value); }}
|
||||
placeholder="e.g. common.conf"
|
||||
className={styles.codeInput}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="after">
|
||||
<Input
|
||||
value={after}
|
||||
onChange={(_e, d) => { setAfter(d.value); }}
|
||||
placeholder="e.g. sshd-aggressive.conf"
|
||||
className={styles.codeInput}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Variables */}
|
||||
<AccordionItem value="variables" className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
{`Variables (${String(varCount)})`}
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<KVEditor entries={variables} onChange={setVariables} />
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Definition */}
|
||||
<AccordionItem value="definition" className={styles.accordionItem}>
|
||||
<AccordionHeader>Definition</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<Field label="prefregex">
|
||||
<Textarea
|
||||
value={prefregex}
|
||||
onChange={(_e, d) => { setPrefregex(d.value); }}
|
||||
placeholder="Prefix regex prepended to all failregex patterns"
|
||||
className={styles.codeInput}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<RegexList label="failregex" patterns={failregex} onChange={setFailregex} />
|
||||
<RegexList label="ignoreregex" patterns={ignoreregex} onChange={setIgnoreregex} />
|
||||
|
||||
{/* Advanced optional fields */}
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary style={{ cursor: "pointer", userSelect: "none", marginBottom: 8 }}>
|
||||
Advanced options
|
||||
</summary>
|
||||
<Field label="maxlines">
|
||||
<Input
|
||||
type="number"
|
||||
value={maxlines}
|
||||
onChange={(_e, d) => { setMaxlines(d.value); }}
|
||||
placeholder="e.g. 10"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="datepattern">
|
||||
<Input
|
||||
value={datepattern}
|
||||
onChange={(_e, d) => { setDatepattern(d.value); }}
|
||||
placeholder="e.g. %Y-%m-%d %H:%M:%S"
|
||||
className={styles.codeInput}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="journalmatch">
|
||||
<Input
|
||||
value={journalmatch}
|
||||
onChange={(_e, d) => { setJournalmatch(d.value); }}
|
||||
placeholder="e.g. _SYSTEMD_UNIT=sshd.service"
|
||||
className={styles.codeInput}
|
||||
/>
|
||||
</Field>
|
||||
</details>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterForm (public export)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FilterFormProps {
|
||||
/** Filter base name (e.g. ``"sshd"``). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and renders the structured form editor for one filter.
|
||||
* Shows a spinner while loading and an error message on failure.
|
||||
*/
|
||||
export function FilterForm({ name }: FilterFormProps): React.JSX.Element {
|
||||
const { config, loading, error, save } = useFilterConfig(name);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label={`Loading ${name}…`}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
<SkeletonItem size={32} style={{ marginBottom: 8 }} />
|
||||
<SkeletonItem size={72} />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null || config === null) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error ?? "Failed to load filter config"}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterFormEditor
|
||||
config={config}
|
||||
onSave={save}
|
||||
/>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/config/FiltersTab.tsx
Normal file
111
frontend/src/components/config/FiltersTab.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* FiltersTab — form-based accordion editor for filter.d files.
|
||||
*
|
||||
* Shows one accordion item per filter file. Expanding a panel lazily loads
|
||||
* the parsed filter config and renders a {@link FilterForm}.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { DocumentAdd24Regular } from "@fluentui/react-icons";
|
||||
import { fetchFilterFiles } from "../../api/config";
|
||||
import type { ConfFileEntry } from "../../types/config";
|
||||
import { FilterForm } from "./FilterForm";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/**
|
||||
* Tab component for the form-based filter.d editor.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function FiltersTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchFilterFiles()
|
||||
.then((resp) => {
|
||||
if (!cancelled) {
|
||||
setFiles(resp.files);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load filters");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return (): void => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading filters…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
||||
))}
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tabContent}>
|
||||
<Accordion collapsible>
|
||||
{files.map((f) => (
|
||||
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}>
|
||||
<AccordionHeader>{f.filename}</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<FilterForm name={f.name} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
142
frontend/src/components/config/GlobalTab.tsx
Normal file
142
frontend/src/components/config/GlobalTab.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* GlobalTab — global fail2ban settings editor.
|
||||
*
|
||||
* Provides form fields for log level, log target, database purge age,
|
||||
* and database max matches.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
} from "@fluentui/react-components";
|
||||
import type { GlobalConfigUpdate } from "../../types/config";
|
||||
import { useGlobalConfig } from "../../hooks/useConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/** Available fail2ban log levels in descending severity order. */
|
||||
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
|
||||
|
||||
/**
|
||||
* Tab component for editing global fail2ban configuration.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function GlobalTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { config, loading, error, updateConfig } = useGlobalConfig();
|
||||
const [logLevel, setLogLevel] = useState("");
|
||||
const [logTarget, setLogTarget] = useState("");
|
||||
const [dbPurgeAge, setDbPurgeAge] = useState("");
|
||||
const [dbMaxMatches, setDbMaxMatches] = useState("");
|
||||
|
||||
// Sync local state when config loads for the first time.
|
||||
useEffect(() => {
|
||||
if (config && logLevel === "") {
|
||||
setLogLevel(config.log_level);
|
||||
setLogTarget(config.log_target);
|
||||
setDbPurgeAge(String(config.db_purge_age));
|
||||
setDbMaxMatches(String(config.db_max_matches));
|
||||
}
|
||||
// Only run on first config load.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
const effectiveLogLevel = logLevel || config?.log_level || "";
|
||||
const effectiveLogTarget = logTarget || config?.log_target || "";
|
||||
const effectiveDbPurgeAge =
|
||||
dbPurgeAge || (config ? String(config.db_purge_age) : "");
|
||||
const effectiveDbMaxMatches =
|
||||
dbMaxMatches || (config ? String(config.db_max_matches) : "");
|
||||
|
||||
const updatePayload = useMemo<GlobalConfigUpdate>(() => {
|
||||
const update: GlobalConfigUpdate = {};
|
||||
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
|
||||
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
|
||||
if (effectiveDbPurgeAge)
|
||||
update.db_purge_age = Number(effectiveDbPurgeAge);
|
||||
if (effectiveDbMaxMatches)
|
||||
update.db_max_matches = Number(effectiveDbMaxMatches);
|
||||
return update;
|
||||
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(updatePayload, updateConfig);
|
||||
|
||||
if (loading) return <Spinner label="Loading global config…" />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Log Level">
|
||||
<Select
|
||||
value={effectiveLogLevel}
|
||||
onChange={(_e, d) => {
|
||||
setLogLevel(d.value);
|
||||
}}
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Log Target">
|
||||
<Input
|
||||
value={effectiveLogTarget}
|
||||
placeholder="STDOUT / /var/log/fail2ban.log"
|
||||
onChange={(_e, d) => {
|
||||
setLogTarget(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field
|
||||
label="DB Purge Age (s)"
|
||||
hint="Ban records older than this are removed from the fail2ban database."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbPurgeAge}
|
||||
onChange={(_e, d) => {
|
||||
setDbPurgeAge(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="DB Max Matches"
|
||||
hint="Maximum number of log-line matches stored per ban record."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbMaxMatches}
|
||||
onChange={(_e, d) => {
|
||||
setDbMaxMatches(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
474
frontend/src/components/config/JailFileForm.tsx
Normal file
474
frontend/src/components/config/JailFileForm.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* JailFileForm — structured form editor for a single ``jail.d/*.conf`` file.
|
||||
*
|
||||
* Renders each jail section in the file as an accordion panel with fields for
|
||||
* all common jail settings plus log paths, action references, and extra keys.
|
||||
*
|
||||
* All fields auto-save through ``useAutoSave``.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Switch,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { Add24Regular, Delete24Regular, DocumentAdd24Regular } from "@fluentui/react-icons";
|
||||
import type { JailFileConfig, JailFileConfigUpdate, JailSectionConfig } from "../../types/config";
|
||||
import { useJailFileConfig } from "../../hooks/useJailFileConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StringListEditor — add / remove lines in a list field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StringListEditorProps {
|
||||
items: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
addLabel?: string;
|
||||
}
|
||||
|
||||
function StringListEditor({
|
||||
items,
|
||||
onChange,
|
||||
placeholder,
|
||||
addLabel = "Add entry",
|
||||
}: StringListEditorProps): React.JSX.Element {
|
||||
const handleChange = (index: number, value: string): void => {
|
||||
const next = [...items];
|
||||
next[index] = value;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleDelete = (index: number): void => {
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleAdd = (): void => {
|
||||
onChange([...items, ""]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} style={{ display: "flex", gap: 4, marginBottom: 4, alignItems: "center" }}>
|
||||
<Input
|
||||
value={item}
|
||||
size="small"
|
||||
style={{ flex: 1, fontFamily: "monospace" }}
|
||||
placeholder={placeholder}
|
||||
aria-label={`Entry ${String(index + 1)}${item ? `: ${item}` : ""}`}
|
||||
onChange={(_e, d) => { handleChange(index, d.value); }}
|
||||
/>
|
||||
<Button
|
||||
icon={<Delete24Regular />}
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => { handleDelete(index); }}
|
||||
aria-label="Remove entry"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
icon={<Add24Regular />}
|
||||
size="small"
|
||||
appearance="outline"
|
||||
onClick={handleAdd}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KVEditor — key-value pair list (for extra settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface KVEditorProps {
|
||||
entries: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const rows = Object.entries(entries);
|
||||
|
||||
const handleKeyChange = (oldKey: string, newKey: string): void => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(entries)) {
|
||||
next[k === oldKey ? newKey : k] = v;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleValueChange = (key: string, value: string): void => {
|
||||
onChange({ ...entries, [key]: value });
|
||||
};
|
||||
|
||||
const handleDelete = (key: string): void => {
|
||||
const { [key]: _removed, ...rest } = entries;
|
||||
onChange(rest);
|
||||
};
|
||||
|
||||
const handleAdd = (): void => {
|
||||
let newKey = "key";
|
||||
let n = 1;
|
||||
while (newKey in entries) {
|
||||
newKey = `key${String(n)}`;
|
||||
n++;
|
||||
}
|
||||
onChange({ ...entries, [newKey]: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key} className={styles.fieldRow}>
|
||||
<Input
|
||||
value={key}
|
||||
size="small"
|
||||
style={{ width: 140, fontFamily: "monospace" }}
|
||||
aria-label={`Setting name: ${key}`}
|
||||
onChange={(_e, d) => { handleKeyChange(key, d.value); }}
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
size="small"
|
||||
style={{ flex: 1, fontFamily: "monospace" }}
|
||||
aria-label={`Value for ${key}`}
|
||||
onChange={(_e, d) => { handleValueChange(key, d.value); }}
|
||||
/>
|
||||
<Button
|
||||
icon={<Delete24Regular />}
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => { handleDelete(key); }}
|
||||
aria-label={`Delete key ${key}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
icon={<Add24Regular />}
|
||||
size="small"
|
||||
appearance="outline"
|
||||
onClick={handleAdd}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
Add setting
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailSectionPanel — form fields for one [jailname] section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BACKENDS = ["", "auto", "polling", "gamin", "pyinotify", "systemd"] as const;
|
||||
|
||||
interface JailSectionPanelProps {
|
||||
jailName: string;
|
||||
section: JailSectionConfig;
|
||||
onChange: (next: JailSectionConfig) => void;
|
||||
}
|
||||
|
||||
function JailSectionPanel({
|
||||
jailName,
|
||||
section,
|
||||
onChange,
|
||||
}: JailSectionPanelProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const update = useCallback(
|
||||
(patch: Partial<JailSectionConfig>): void => {
|
||||
onChange({ ...section, ...patch });
|
||||
},
|
||||
[onChange, section]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Core fields grid */}
|
||||
<div className={styles.sectionCard}>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Enabled">
|
||||
<Switch
|
||||
checked={section.enabled ?? false}
|
||||
onChange={(_e, d) => { update({ enabled: d.checked }); }}
|
||||
aria-label={`Enable jail ${jailName}`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Port">
|
||||
<Input
|
||||
value={section.port ?? ""}
|
||||
size="small"
|
||||
placeholder="e.g. ssh or 22"
|
||||
onChange={(_e, d) => { update({ port: d.value || null }); }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Filter">
|
||||
<Input
|
||||
value={section.filter ?? ""}
|
||||
size="small"
|
||||
placeholder="e.g. sshd"
|
||||
className={styles.codeInput}
|
||||
onChange={(_e, d) => { update({ filter: d.value || null }); }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Backend">
|
||||
<Select
|
||||
size="small"
|
||||
value={section.backend ?? ""}
|
||||
onChange={(_e, d) => { update({ backend: d.value || null }); }}
|
||||
>
|
||||
{BACKENDS.map((b) => (
|
||||
<option key={b} value={b}>{b === "" ? "(default)" : b}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Max Retry">
|
||||
<Input
|
||||
value={section.maxretry !== null ? String(section.maxretry) : ""}
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="e.g. 5"
|
||||
onChange={(_e, d) => {
|
||||
const n = parseInt(d.value, 10);
|
||||
update({ maxretry: d.value === "" ? null : isNaN(n) ? null : n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Find Time (s)">
|
||||
<Input
|
||||
value={section.findtime !== null ? String(section.findtime) : ""}
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="e.g. 600"
|
||||
onChange={(_e, d) => {
|
||||
const n = parseInt(d.value, 10);
|
||||
update({ findtime: d.value === "" ? null : isNaN(n) ? null : n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ban Time (s)">
|
||||
<Input
|
||||
value={section.bantime !== null ? String(section.bantime) : ""}
|
||||
size="small"
|
||||
type="number"
|
||||
placeholder="e.g. 3600"
|
||||
onChange={(_e, d) => {
|
||||
const n = parseInt(d.value, 10);
|
||||
update({ bantime: d.value === "" ? null : isNaN(n) ? null : n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log paths */}
|
||||
<Accordion multiple collapsible defaultOpenItems={["logpath"]}>
|
||||
<AccordionItem value="logpath" className={styles.accordionItem}>
|
||||
<AccordionHeader>Log Paths ({section.logpath.length})</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<StringListEditor
|
||||
items={section.logpath}
|
||||
onChange={(next) => { update({ logpath: next }); }}
|
||||
placeholder="e.g. /var/log/auth.log"
|
||||
addLabel="Add log path"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Actions */}
|
||||
<AccordionItem value="actions" className={styles.accordionItem}>
|
||||
<AccordionHeader>Actions ({section.action.length})</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<StringListEditor
|
||||
items={section.action}
|
||||
onChange={(next) => { update({ action: next }); }}
|
||||
placeholder="e.g. iptables-multiport[name=SSH, port=ssh]"
|
||||
addLabel="Add action"
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Extra settings */}
|
||||
{(Object.keys(section.extra).length > 0) && (
|
||||
<AccordionItem value="extra" className={styles.accordionItem}>
|
||||
<AccordionHeader>Extra Settings ({Object.keys(section.extra).length})</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<div className={styles.sectionCard}>
|
||||
<KVEditor
|
||||
entries={section.extra}
|
||||
onChange={(next) => { update({ extra: next }); }}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailFileFormInner — rendered once config is loaded
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailFileFormInnerProps {
|
||||
config: JailFileConfig;
|
||||
onSave: (update: JailFileConfigUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
function JailFileFormInner({
|
||||
config,
|
||||
onSave,
|
||||
}: JailFileFormInnerProps): React.JSX.Element {
|
||||
const [jails, setJails] = useState<Record<string, JailSectionConfig>>(config.jails);
|
||||
|
||||
// Reset when a freshly-loaded config arrives.
|
||||
useEffect(() => {
|
||||
setJails(config.jails);
|
||||
}, [config]);
|
||||
|
||||
const autoSavePayload = useMemo<JailFileConfigUpdate>(
|
||||
() => ({ jails }),
|
||||
[jails]
|
||||
);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(autoSavePayload, onSave);
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(jailName: string, next: JailSectionConfig): void => {
|
||||
setJails((prev) => ({ ...prev, [jailName]: next }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const styles = useConfigStyles();
|
||||
const jailNames = Object.keys(jails);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div className={styles.buttonRow} style={{ marginBottom: 8 }}>
|
||||
<Text size={500} weight="semibold">{config.filename}</Text>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{jailNames.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<DocumentAdd24Regular
|
||||
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No jail sections found in this file.
|
||||
</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Add a <code>[jailname]</code> section to the file to define jails.
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Accordion multiple collapsible defaultOpenItems={jailNames}>
|
||||
{jailNames.map((jailName) => {
|
||||
const section = jails[jailName];
|
||||
if (section === undefined) return null;
|
||||
return (
|
||||
<AccordionItem key={jailName} value={jailName} className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
<Badge appearance="filled" color="informative" style={{ marginRight: 6 }}>
|
||||
{jailName}
|
||||
</Badge>
|
||||
{section.enabled === true
|
||||
? "(enabled)"
|
||||
: section.enabled === false
|
||||
? "(disabled)"
|
||||
: ""}
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<JailSectionPanel
|
||||
jailName={jailName}
|
||||
section={section}
|
||||
onChange={(next) => { handleSectionChange(jailName, next); }}
|
||||
/>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailFileForm — public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailFileFormProps {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and render a structured form editor for a ``jail.d/*.conf`` file.
|
||||
*
|
||||
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
|
||||
*/
|
||||
export function JailFileForm({ filename }: JailFileFormProps): React.JSX.Element {
|
||||
const { config, loading, error, save } = useJailFileConfig(filename);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading jail file config…">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
<SkeletonItem size={32} />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<MessageBar intent="error" style={{ margin: "8px 0" }}>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
if (config === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <JailFileFormInner config={config} onSave={save} />;
|
||||
}
|
||||
252
frontend/src/components/config/JailFilesTab.tsx
Normal file
252
frontend/src/components/config/JailFilesTab.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* JailFilesTab — manage jail.d config files with a structured form editor.
|
||||
*
|
||||
* Displays all jail.d config files in a collapsible accordion. Each file
|
||||
* panel renders a ``JailFileForm`` with per-section auto-save editing. The
|
||||
* file-level enable/disable toggle remains in the accordion header.
|
||||
*
|
||||
* A "Create new jail file" section at the bottom allows adding new files.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Switch,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { Add24Regular, ArrowClockwise24Regular, DocumentAdd24Regular } from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
createJailConfigFile,
|
||||
fetchJailConfigFiles,
|
||||
setJailConfigFileEnabled,
|
||||
} from "../../api/config";
|
||||
import type { JailConfigFile } from "../../types/config";
|
||||
import { JailFileForm } from "./JailFileForm";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/**
|
||||
* Tab component for managing jail.d configuration files with structured forms.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function JailFilesTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [files, setFiles] = useState<JailConfigFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
// Create new file form state
|
||||
const [newFilename, setNewFilename] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const loadFiles = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetchJailConfigFiles();
|
||||
setFiles(resp.files);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "Failed to load jail config files.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (filename: string, enabled: boolean): Promise<void> => {
|
||||
setToggling(filename);
|
||||
setMsg(null);
|
||||
try {
|
||||
await setJailConfigFileEnabled(filename, { enabled });
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.filename === filename ? { ...f, enabled } : f)),
|
||||
);
|
||||
setMsg({ text: `${filename} ${enabled ? "enabled" : "disabled"}.`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Toggle failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(async (): Promise<void> => {
|
||||
const name = newFilename.trim();
|
||||
if (!name) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await createJailConfigFile({ name, content: `# ${name}\n` });
|
||||
setNewFilename("");
|
||||
await loadFiles();
|
||||
} catch (err: unknown) {
|
||||
setCreateError(err instanceof ApiError ? err.message : "Failed to create file.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [newFilename, loadFiles]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading jail config files…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
||||
))}
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Files in <code>jail.d/</code>. Toggle the switch to enable or disable a
|
||||
jail config file. Changes take effect on the next fail2ban reload.
|
||||
</Text>
|
||||
|
||||
{msg !== null && (
|
||||
<MessageBar
|
||||
intent={msg.ok ? "success" : "error"}
|
||||
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={() => { void loadFiles(); }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
<DocumentAdd24Regular
|
||||
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No files found in jail.d/.
|
||||
</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Use the form below to create your first jail config file.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<AccordionItem key={file.filename} value={file.filename} className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
}}
|
||||
>
|
||||
<span className={styles.codeFont}>{file.filename}</span>
|
||||
<Switch
|
||||
checked={file.enabled}
|
||||
disabled={toggling === file.filename}
|
||||
label={file.enabled ? "Enabled" : "Disabled"}
|
||||
onChange={(_e, d) => {
|
||||
void handleToggleEnabled(file.filename, d.checked);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
/>
|
||||
</span>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<JailFileForm filename={file.filename} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
{/* Create new jail file */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: tokens.spacingVerticalXL,
|
||||
padding: tokens.spacingVerticalM,
|
||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
}}
|
||||
>
|
||||
<Text size={400} weight="semibold" block style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
Create New Jail File
|
||||
</Text>
|
||||
{createError !== null && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<MessageBarBody>{createError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: tokens.spacingHorizontalM, alignItems: "flex-end" }}>
|
||||
<Field label="Filename" style={{ flex: 1 }}>
|
||||
<Input
|
||||
value={newFilename}
|
||||
placeholder="e.g. myapp.conf"
|
||||
onChange={(_e, d) => { setNewFilename(d.value); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { void handleCreate(); }
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Add24Regular />}
|
||||
disabled={creating || !newFilename.trim()}
|
||||
onClick={() => { void handleCreate(); }}
|
||||
>
|
||||
{creating ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
534
frontend/src/components/config/JailsTab.tsx
Normal file
534
frontend/src/components/config/JailsTab.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* JailsTab and JailAccordionPanel — per-jail configuration editor.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Switch,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowClockwise24Regular, Dismiss24Regular, LockClosed24Regular } from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
addLogPath,
|
||||
deleteLogPath,
|
||||
} from "../../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
} from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { useJailConfigs } from "../../hooks/useConfig";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { RegexList } from "./RegexList";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailAccordionPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailAccordionPanelProps {
|
||||
jail: JailConfig;
|
||||
onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable configuration panel for a single fail2ban jail.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
function JailAccordionPanel({
|
||||
jail,
|
||||
onSave,
|
||||
}: JailAccordionPanelProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [banTime, setBanTime] = useState(String(jail.ban_time));
|
||||
const [findTime, setFindTime] = useState(String(jail.find_time));
|
||||
const [maxRetry, setMaxRetry] = useState(String(jail.max_retry));
|
||||
const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex);
|
||||
const [ignoreRegex, setIgnoreRegex] = useState<string[]>(jail.ignore_regex);
|
||||
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
|
||||
const [datePattern, setDatePattern] = useState(jail.date_pattern ?? "");
|
||||
const [dnsMode, setDnsMode] = useState(jail.use_dns);
|
||||
const [prefRegex, setPrefRegex] = useState(jail.prefregex);
|
||||
const [deletingPath, setDeletingPath] = useState<string | null>(null);
|
||||
const [newLogPath, setNewLogPath] = useState("");
|
||||
const [newLogPathTail, setNewLogPathTail] = useState(true);
|
||||
const [addingLogPath, setAddingLogPath] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
// 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 [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 handleDeleteLogPath = useCallback(
|
||||
async (path: string) => {
|
||||
setDeletingPath(path);
|
||||
setMsg(null);
|
||||
try {
|
||||
await deleteLogPath(jail.name, path);
|
||||
setLogPaths((prev) => prev.filter((p) => p !== path));
|
||||
setMsg({ text: `Removed log path: ${path}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Delete failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setDeletingPath(null);
|
||||
}
|
||||
},
|
||||
[jail.name],
|
||||
);
|
||||
|
||||
const handleAddLogPath = useCallback(async () => {
|
||||
const trimmed = newLogPath.trim();
|
||||
if (!trimmed) return;
|
||||
setAddingLogPath(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail };
|
||||
await addLogPath(jail.name, req);
|
||||
setLogPaths((prev) => [...prev, trimmed]);
|
||||
setNewLogPath("");
|
||||
setMsg({ text: `Added log path: ${trimmed}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Failed to add log path.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setAddingLogPath(false);
|
||||
}
|
||||
}, [jail.name, newLogPath, newLogPathTail]);
|
||||
|
||||
const autoSavePayload = useMemo<JailConfigUpdate>(
|
||||
() => ({
|
||||
ban_time: Number(banTime) || jail.ban_time,
|
||||
find_time: Number(findTime) || jail.find_time,
|
||||
max_retry: Number(maxRetry) || jail.max_retry,
|
||||
fail_regex: failRegex,
|
||||
ignore_regex: ignoreRegex,
|
||||
date_pattern: datePattern !== "" ? datePattern : null,
|
||||
dns_mode: dnsMode,
|
||||
prefregex: prefRegex !== "" ? prefRegex : null,
|
||||
bantime_escalation: {
|
||||
increment: escEnabled,
|
||||
factor: escFactor !== "" ? Number(escFactor) : null,
|
||||
formula: escFormula !== "" ? escFormula : null,
|
||||
multipliers: escMultipliers !== "" ? escMultipliers : null,
|
||||
max_time: escMaxTime !== "" ? Number(escMaxTime) : null,
|
||||
rnd_time: escRndTime !== "" ? Number(escRndTime) : null,
|
||||
overall_jails: escOverallJails,
|
||||
},
|
||||
}),
|
||||
[
|
||||
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
|
||||
dnsMode, prefRegex, escEnabled, escFactor, escFormula, escMultipliers,
|
||||
escMaxTime, escRndTime, escOverallJails,
|
||||
jail.ban_time, jail.find_time, jail.max_retry,
|
||||
],
|
||||
);
|
||||
|
||||
const saveCurrent = useCallback(
|
||||
async (update: JailConfigUpdate): Promise<void> => {
|
||||
await onSave(jail.name, update);
|
||||
},
|
||||
[jail.name, onSave],
|
||||
);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(autoSavePayload, saveCurrent);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && (
|
||||
<MessageBar intent={msg.ok ? "success" : "error"}>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Ban Time (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={banTime}
|
||||
onChange={(_e, d) => {
|
||||
setBanTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Find Time (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={findTime}
|
||||
onChange={(_e, d) => {
|
||||
setFindTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max Retry">
|
||||
<Input
|
||||
type="number"
|
||||
value={maxRetry}
|
||||
onChange={(_e, d) => {
|
||||
setMaxRetry(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Backend">
|
||||
<Input readOnly value={jail.backend} />
|
||||
</Field>
|
||||
<Field label="Log Encoding">
|
||||
<Input readOnly value={jail.log_encoding} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Date Pattern" hint="Leave blank for auto-detect.">
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
placeholder="auto-detect"
|
||||
value={datePattern}
|
||||
onChange={(_e, d) => {
|
||||
setDatePattern(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DNS Mode">
|
||||
<Select
|
||||
value={dnsMode}
|
||||
onChange={(_e, d) => {
|
||||
setDnsMode(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="yes">yes — resolve hostnames</option>
|
||||
<option value="warn">warn — resolve and warn</option>
|
||||
<option value="no">no — skip hostname resolution</option>
|
||||
<option value="raw">raw — use value as-is</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Prefix Regex"
|
||||
hint="Prepended to every failregex for pre-filtering. Leave blank to disable."
|
||||
>
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
placeholder="e.g. ^%(__prefix_line)s"
|
||||
value={prefRegex}
|
||||
onChange={(_e, d) => {
|
||||
setPrefRegex(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Log Paths">
|
||||
{logPaths.length === 0 ? (
|
||||
<Text className={styles.infoText} size={200}>
|
||||
(none)
|
||||
</Text>
|
||||
) : (
|
||||
logPaths.map((p) => (
|
||||
<div key={p} className={styles.regexItem}>
|
||||
<span className={styles.codeFont} style={{ flexGrow: 1 }}>
|
||||
{p}
|
||||
</span>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<Dismiss24Regular />}
|
||||
size="small"
|
||||
disabled={deletingPath === p}
|
||||
title="Remove log path"
|
||||
onClick={() => void handleDeleteLogPath(p)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{/* Add log path inline form */}
|
||||
<div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}>
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
style={{ flexGrow: 1 }}
|
||||
placeholder="/var/log/example.log"
|
||||
value={newLogPath}
|
||||
disabled={addingLogPath}
|
||||
aria-label="New log path"
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPath(d.value);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label={newLogPathTail ? "tail" : "head"}
|
||||
checked={newLogPathTail}
|
||||
onChange={(_e, d) => {
|
||||
setNewLogPathTail(d.checked);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
aria-label="Add log path"
|
||||
disabled={addingLogPath || !newLogPath.trim()}
|
||||
onClick={() => void handleAddLogPath()}
|
||||
>
|
||||
{addingLogPath ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<RegexList
|
||||
label="Fail Regex"
|
||||
patterns={failRegex}
|
||||
onChange={setFailRegex}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<RegexList
|
||||
label="Ignore Regex"
|
||||
patterns={ignoreRegex}
|
||||
onChange={setIgnoreRegex}
|
||||
/>
|
||||
</div>
|
||||
{jail.actions.length > 0 && (
|
||||
<Field label="Actions">
|
||||
<div>
|
||||
{jail.actions.map((a) => (
|
||||
<Badge
|
||||
key={a}
|
||||
appearance="tint"
|
||||
color="informative"
|
||||
style={{ marginRight: tokens.spacingHorizontalXS }}
|
||||
>
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Ban-time Escalation */}
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<Text weight="semibold" size={400} block>
|
||||
Ban-time Escalation
|
||||
</Text>
|
||||
<Switch
|
||||
label="Enable incremental banning"
|
||||
checked={escEnabled}
|
||||
onChange={(_e, d) => {
|
||||
setEscEnabled(d.checked);
|
||||
}}
|
||||
/>
|
||||
{escEnabled && (
|
||||
<div>
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Factor">
|
||||
<Input
|
||||
type="number"
|
||||
value={escFactor}
|
||||
onChange={(_e, d) => {
|
||||
setEscFactor(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max Time (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={escMaxTime}
|
||||
onChange={(_e, d) => {
|
||||
setEscMaxTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Random Jitter (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={escRndTime}
|
||||
onChange={(_e, d) => {
|
||||
setEscRndTime(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Formula">
|
||||
<Input
|
||||
value={escFormula}
|
||||
onChange={(_e, d) => {
|
||||
setEscFormula(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Multipliers (space-separated)">
|
||||
<Input
|
||||
value={escMultipliers}
|
||||
onChange={(_e, d) => {
|
||||
setEscMultipliers(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Switch
|
||||
label="Count repeat offences across all jails"
|
||||
checked={escOverallJails}
|
||||
onChange={(_e, d) => {
|
||||
setEscOverallJails(d.checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailsTab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tab component showing all active fail2ban jails with editable configs.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function JailsTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { jails, loading, error, refresh, updateJail, reloadAll } =
|
||||
useJailConfigs();
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [reloadMsg, setReloadMsg] = useState<string | null>(null);
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
setReloading(true);
|
||||
setReloadMsg(null);
|
||||
try {
|
||||
await reloadAll();
|
||||
setReloadMsg("fail2ban reloaded.");
|
||||
} catch (err: unknown) {
|
||||
setReloadMsg(
|
||||
err instanceof ApiError ? err.message : "Reload failed.",
|
||||
);
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}, [reloadAll]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading jail configs…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
||||
))}
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={refresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
disabled={reloading}
|
||||
onClick={() => void handleReload()}
|
||||
>
|
||||
{reloading ? "Reloading…" : "Reload fail2ban"}
|
||||
</Button>
|
||||
</div>
|
||||
{reloadMsg && (
|
||||
<MessageBar style={{ marginTop: tokens.spacingVerticalS }} intent="info">
|
||||
<MessageBarBody>{reloadMsg}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{jails.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
212
frontend/src/components/config/MapTab.tsx
Normal file
212
frontend/src/components/config/MapTab.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* MapTab — world map color threshold configuration editor.
|
||||
*
|
||||
* Allows the user to set the low / medium / high ban-count thresholds
|
||||
* that drive country fill colors on the World Map page.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
} from "../../api/config";
|
||||
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner form — only mounted after data is loaded.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MapFormProps {
|
||||
initial: MapColorThresholdsResponse;
|
||||
}
|
||||
|
||||
function MapForm({ initial }: MapFormProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
|
||||
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
|
||||
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
|
||||
|
||||
const high = Number(thresholdHigh);
|
||||
const medium = Number(thresholdMedium);
|
||||
const low = Number(thresholdLow);
|
||||
|
||||
const validationError = useMemo<string | null>(() => {
|
||||
if (isNaN(high) || isNaN(medium) || isNaN(low))
|
||||
return "All thresholds must be valid numbers.";
|
||||
if (high <= 0 || medium <= 0 || low <= 0)
|
||||
return "All thresholds must be positive integers.";
|
||||
if (!(high > medium && medium > low))
|
||||
return "Thresholds must satisfy: high > medium > low.";
|
||||
return null;
|
||||
}, [high, medium, low]);
|
||||
|
||||
// Only pass a new payload to useAutoSave when all values are valid.
|
||||
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
|
||||
threshold_high: initial.threshold_high,
|
||||
threshold_medium: initial.threshold_medium,
|
||||
threshold_low: initial.threshold_low,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (validationError !== null) return;
|
||||
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
|
||||
}, [high, medium, low, validationError]);
|
||||
|
||||
const saveThresholds = useCallback(
|
||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||
await updateMapColorThresholds(payload);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(validPayload, saveThresholds);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Map Color Thresholds
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Configure the ban count thresholds that determine country fill colors on
|
||||
the World Map. Countries with zero bans remain transparent. Colors
|
||||
smoothly interpolate between thresholds.
|
||||
</Text>
|
||||
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={validationError ? "idle" : saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<MessageBarBody>{validationError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Low Threshold (Green)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdLow}
|
||||
onChange={(_, d) => {
|
||||
setThresholdLow(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Medium Threshold (Yellow)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdMedium}
|
||||
onChange={(_, d) => {
|
||||
setThresholdMedium(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="High Threshold (Red)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdHigh}
|
||||
onChange={(_, d) => {
|
||||
setThresholdHigh(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
size={200}
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
• 1 to {thresholdLow}: Light green → Full green
|
||||
<br />• {thresholdLow} to {thresholdMedium}: Green → Yellow
|
||||
<br />• {thresholdMedium} to {thresholdHigh}: Yellow → Red
|
||||
<br />• {thresholdHigh}+: Solid red
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outer loader component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tab component for editing world-map ban-count color thresholds.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function MapTab(): React.JSX.Element {
|
||||
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setThresholds(data);
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!thresholds && !loadError) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading map settings…">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{loadError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
if (!thresholds) return <></>;
|
||||
|
||||
return <MapForm initial={thresholds} />;
|
||||
}
|
||||
|
||||
|
||||
103
frontend/src/components/config/RegexList.tsx
Normal file
103
frontend/src/components/config/RegexList.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* RegexList — editable list of regex patterns.
|
||||
*
|
||||
* Renders a list of monospace inputs with add/delete controls.
|
||||
* Used in jail config panels and the filter form.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, Input, Text } from "@fluentui/react-components";
|
||||
import { Dismiss24Regular } from "@fluentui/react-icons";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
export interface RegexListProps {
|
||||
/** Section label displayed above the list. */
|
||||
label: string;
|
||||
/** Current list of regex patterns. */
|
||||
patterns: string[];
|
||||
/** Called when the list changes (add, delete, or edit). */
|
||||
onChange: (next: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an editable list of regex patterns with add and delete controls.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function RegexList({
|
||||
label,
|
||||
patterns,
|
||||
onChange,
|
||||
}: RegexListProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [newPattern, setNewPattern] = useState("");
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const p = newPattern.trim();
|
||||
if (p) {
|
||||
onChange([...patterns, p]);
|
||||
setNewPattern("");
|
||||
}
|
||||
}, [newPattern, patterns, onChange]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(idx: number) => {
|
||||
onChange(patterns.filter((_, i) => i !== idx));
|
||||
},
|
||||
[patterns, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text size={200} weight="semibold">
|
||||
{label}
|
||||
</Text>
|
||||
{patterns.length === 0 && (
|
||||
<Text className={styles.infoText} size={200}>
|
||||
{" "}
|
||||
(none)
|
||||
</Text>
|
||||
)}
|
||||
{patterns.map((p, i) => (
|
||||
<div key={i} className={styles.regexItem}>
|
||||
<Input
|
||||
className={styles.regexInput}
|
||||
value={p}
|
||||
aria-label={`${label} pattern ${String(i + 1)}`}
|
||||
onChange={(_e, d) => {
|
||||
const next = [...patterns];
|
||||
next[i] = d.value;
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<Dismiss24Regular />}
|
||||
size="small"
|
||||
aria-label={`Remove ${label} pattern ${String(i + 1)}`}
|
||||
onClick={() => {
|
||||
handleDelete(i);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.regexItem}>
|
||||
<Input
|
||||
className={styles.regexInput}
|
||||
placeholder="New pattern…"
|
||||
value={newPattern}
|
||||
onChange={(_e, d) => {
|
||||
setNewPattern(d.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd();
|
||||
}}
|
||||
/>
|
||||
<Button size="small" onClick={handleAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
frontend/src/components/config/RegexTesterTab.tsx
Normal file
222
frontend/src/components/config/RegexTesterTab.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* RegexTesterTab — live regex pattern tester and log file preview.
|
||||
*
|
||||
* Provides two panels:
|
||||
* 1. Single-line tester: paste a log line and a regex, get a match result.
|
||||
* 2. Log file preview: read N lines from a server file and highlight matches.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Text,
|
||||
Textarea,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
Checkmark24Regular,
|
||||
Dismiss24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { useLogPreview, useRegexTester } from "../../hooks/useConfig";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/**
|
||||
* Tab component for testing regex patterns against log lines or full files.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function RegexTesterTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { result, testing, test } = useRegexTester();
|
||||
const { preview, loading: previewing, run: runPreview } = useLogPreview();
|
||||
const [logLine, setLogLine] = useState("");
|
||||
const [pattern, setPattern] = useState("");
|
||||
const [previewPath, setPreviewPath] = useState("");
|
||||
const [previewLines, setPreviewLines] = useState("200");
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
if (!logLine.trim() || !pattern.trim()) return;
|
||||
await test({ log_line: logLine, fail_regex: pattern });
|
||||
}, [logLine, pattern, test]);
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!previewPath.trim() || !pattern.trim()) return;
|
||||
await runPreview({
|
||||
log_path: previewPath,
|
||||
fail_regex: pattern,
|
||||
num_lines: Number(previewLines) || 200,
|
||||
});
|
||||
}, [previewPath, pattern, previewLines, runPreview]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Single-line tester */}
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Regex Tester
|
||||
</Text>
|
||||
<Text size={200} className={styles.infoText} block>
|
||||
Test a pattern against a single sample log line.
|
||||
</Text>
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Field label="Fail Regex Pattern">
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
value={pattern}
|
||||
placeholder="e.g. (?P<host>\S+)"
|
||||
onChange={(_e, d) => {
|
||||
setPattern(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Sample Log Line"
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
<Textarea
|
||||
className={styles.codeFont}
|
||||
value={logLine}
|
||||
placeholder="Paste a log line here…"
|
||||
rows={3}
|
||||
onChange={(_e, d) => {
|
||||
setLogLine(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={testing || !logLine.trim() || !pattern.trim()}
|
||||
onClick={() => void handleTest()}
|
||||
>
|
||||
{testing ? "Testing…" : "Test Pattern"}
|
||||
</Button>
|
||||
</div>
|
||||
{result && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: tokens.spacingVerticalM,
|
||||
padding: tokens.spacingVerticalS,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
}}
|
||||
>
|
||||
{result.error ? (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>Regex error: {result.error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : (
|
||||
<>
|
||||
<Badge
|
||||
size="large"
|
||||
appearance="filled"
|
||||
color={result.matched ? "success" : "danger"}
|
||||
icon={
|
||||
result.matched ? (
|
||||
<Checkmark24Regular />
|
||||
) : (
|
||||
<Dismiss24Regular />
|
||||
)
|
||||
}
|
||||
>
|
||||
{result.matched ? "Matched" : "No match"}
|
||||
</Badge>
|
||||
{result.matched && result.groups.length > 0 && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
<Text size={200} weight="semibold">
|
||||
Captured groups:
|
||||
</Text>
|
||||
{result.groups.map((g, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
appearance="tint"
|
||||
color="informative"
|
||||
style={{ marginLeft: tokens.spacingHorizontalXS }}
|
||||
className={styles.codeFont}
|
||||
>
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log file preview */}
|
||||
<div style={{ marginTop: tokens.spacingVerticalXL }}>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Log File Preview
|
||||
</Text>
|
||||
<Text size={200} className={styles.infoText} block>
|
||||
Read the last N lines from a log file on the server and highlight
|
||||
matches.
|
||||
</Text>
|
||||
<div
|
||||
className={styles.fieldRow}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
<Field label="Log File Path">
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
value={previewPath}
|
||||
placeholder="/var/log/auth.log"
|
||||
onChange={(_e, d) => {
|
||||
setPreviewPath(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Lines to Read">
|
||||
<Input
|
||||
type="number"
|
||||
value={previewLines}
|
||||
onChange={(_e, d) => {
|
||||
setPreviewLines(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
disabled={previewing || !previewPath.trim() || !pattern.trim()}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
{previewing ? "Loading…" : "Preview Log"}
|
||||
</Button>
|
||||
</div>
|
||||
{preview && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
{preview.regex_error ? (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{preview.regex_error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
) : (
|
||||
<>
|
||||
<Text size={200}>
|
||||
{preview.matched_count} / {preview.total_lines} lines matched
|
||||
</Text>
|
||||
<div className={styles.previewArea}>
|
||||
{preview.lines.map((ln, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.logLine} ${ln.matched ? styles.matched : styles.notMatched}`}
|
||||
>
|
||||
{ln.line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
frontend/src/components/config/ServerTab.tsx
Normal file
194
frontend/src/components/config/ServerTab.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* ServerTab — fail2ban server-level settings editor.
|
||||
*
|
||||
* Provides form fields for live server settings (log level, log target,
|
||||
* DB purge age, DB max matches) and a "Flush Logs" action button.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
DocumentArrowDown24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { ServerSettingsUpdate } from "../../types/config";
|
||||
import { useServerSettings } from "../../hooks/useConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/** Available fail2ban log levels in descending severity order. */
|
||||
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
|
||||
|
||||
/**
|
||||
* Tab component for editing live fail2ban server settings.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function ServerTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { settings, loading, error, updateSettings, flush } =
|
||||
useServerSettings();
|
||||
const [logLevel, setLogLevel] = useState("");
|
||||
const [logTarget, setLogTarget] = useState("");
|
||||
const [dbPurgeAge, setDbPurgeAge] = useState("");
|
||||
const [dbMaxMatches, setDbMaxMatches] = useState("");
|
||||
const [flushing, setFlushing] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
||||
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
||||
const effectiveDbPurgeAge =
|
||||
dbPurgeAge || (settings ? String(settings.db_purge_age) : "");
|
||||
const effectiveDbMaxMatches =
|
||||
dbMaxMatches || (settings ? String(settings.db_max_matches) : "");
|
||||
|
||||
const updatePayload = useMemo<ServerSettingsUpdate>(() => {
|
||||
const update: ServerSettingsUpdate = {};
|
||||
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
|
||||
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
|
||||
if (effectiveDbPurgeAge)
|
||||
update.db_purge_age = Number(effectiveDbPurgeAge);
|
||||
if (effectiveDbMaxMatches)
|
||||
update.db_max_matches = Number(effectiveDbMaxMatches);
|
||||
return update;
|
||||
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(updatePayload, updateSettings);
|
||||
|
||||
const handleFlush = useCallback(async () => {
|
||||
setFlushing(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const result = await flush();
|
||||
setMsg({ text: `Logs flushed: ${result}`, ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Flush failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setFlushing(false);
|
||||
}
|
||||
}, [flush]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading server settings…">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
<SkeletonItem size={32} style={{ marginBottom: 8 }} />
|
||||
<SkeletonItem size={32} />
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Log Level">
|
||||
<Select
|
||||
value={effectiveLogLevel}
|
||||
onChange={(_e, d) => {
|
||||
setLogLevel(d.value);
|
||||
}}
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Log Target">
|
||||
<Input
|
||||
value={effectiveLogTarget}
|
||||
placeholder="STDOUT / /var/log/fail2ban.log"
|
||||
onChange={(_e, d) => {
|
||||
setLogTarget(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="DB Path">
|
||||
<Input
|
||||
readOnly
|
||||
value={settings?.db_path ?? ""}
|
||||
className={styles.codeFont}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Syslog Socket">
|
||||
<Input
|
||||
readOnly
|
||||
value={settings?.syslog_socket ?? "(not configured)"}
|
||||
className={styles.codeFont}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="DB Purge Age (s)">
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbPurgeAge}
|
||||
onChange={(_e, d) => {
|
||||
setDbPurgeAge(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DB Max Matches">
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbMaxMatches}
|
||||
onChange={(_e, d) => {
|
||||
setDbMaxMatches(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<DocumentArrowDown24Regular />}
|
||||
disabled={flushing}
|
||||
onClick={() => void handleFlush()}
|
||||
>
|
||||
{flushing ? "Flushing…" : "Flush Logs"}
|
||||
</Button>
|
||||
</div>
|
||||
{msg && (
|
||||
<MessageBar intent={msg.ok ? "success" : "error"}>
|
||||
<MessageBarBody>{msg.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/config/__tests__/ActionForm.test.tsx
Normal file
113
frontend/src/components/config/__tests__/ActionForm.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { ActionForm } from "../ActionForm";
|
||||
import type { ActionConfig } from "../../../types/config";
|
||||
|
||||
// Mock the useActionConfig hook so tests don't make real API calls.
|
||||
vi.mock("../../../hooks/useActionConfig");
|
||||
|
||||
import { useActionConfig } from "../../../hooks/useActionConfig";
|
||||
|
||||
const mockUseActionConfig = vi.mocked(useActionConfig);
|
||||
|
||||
const mockConfig: ActionConfig = {
|
||||
name: "iptables",
|
||||
filename: "iptables.conf",
|
||||
before: null,
|
||||
after: null,
|
||||
actionstart: "iptables -N fail2ban-<name>",
|
||||
actionstop: "iptables -F fail2ban-<name>",
|
||||
actioncheck: null,
|
||||
actionban: "iptables -I INPUT -s <ip> -j DROP",
|
||||
actionunban: "iptables -D INPUT -s <ip> -j DROP",
|
||||
actionflush: null,
|
||||
definition_vars: {},
|
||||
init_vars: {},
|
||||
};
|
||||
|
||||
function renderForm(name: string) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ActionForm name={name} />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ActionForm", () => {
|
||||
it("shows skeleton while loading", () => {
|
||||
mockUseActionConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("iptables");
|
||||
expect(screen.getByLabelText(/loading iptables/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when loading fails", () => {
|
||||
mockUseActionConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: false,
|
||||
error: "Timeout",
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("iptables");
|
||||
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback error when config is null with no error message", () => {
|
||||
mockUseActionConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("iptables");
|
||||
expect(screen.getByText(/failed to load action config/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders form accordion sections when config is loaded", () => {
|
||||
mockUseActionConfig.mockReturnValue({
|
||||
config: mockConfig,
|
||||
loading: false,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("iptables");
|
||||
expect(screen.getByText(/includes/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/definition/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the action name to useActionConfig", () => {
|
||||
mockUseActionConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("iptables-multiport");
|
||||
expect(mockUseActionConfig).toHaveBeenCalledWith("iptables-multiport");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { AutoSaveIndicator } from "../AutoSaveIndicator";
|
||||
|
||||
function renderIndicator(props: Parameters<typeof AutoSaveIndicator>[0]) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<AutoSaveIndicator {...props} />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AutoSaveIndicator", () => {
|
||||
it("renders aria-live region when idle with no visible text", () => {
|
||||
renderIndicator({ status: "idle" });
|
||||
const region = screen.getByRole("status");
|
||||
expect(region).toBeInTheDocument();
|
||||
expect(region).toHaveAttribute("aria-live", "polite");
|
||||
// No visible text content for idle
|
||||
expect(region.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows spinner and Saving text when saving", () => {
|
||||
renderIndicator({ status: "saving" });
|
||||
expect(screen.getByText(/saving/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Saved badge when saved", () => {
|
||||
renderIndicator({ status: "saved" });
|
||||
expect(screen.getByText(/saved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error text when status is error", () => {
|
||||
renderIndicator({ status: "error", errorText: "Network error" });
|
||||
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback error text when errorText is null", () => {
|
||||
renderIndicator({ status: "error", errorText: null });
|
||||
expect(screen.getByText(/save failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when retry button is clicked", () => {
|
||||
const onRetry = vi.fn();
|
||||
renderIndicator({ status: "error", errorText: "Oops", onRetry });
|
||||
const retryBtn = screen.getByRole("button", { name: /retry/i });
|
||||
fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
114
frontend/src/components/config/__tests__/FilterForm.test.tsx
Normal file
114
frontend/src/components/config/__tests__/FilterForm.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { FilterForm } from "../FilterForm";
|
||||
import type { FilterConfig } from "../../../types/config";
|
||||
|
||||
// Mock the useFilterConfig hook so tests don't make real API calls.
|
||||
vi.mock("../../../hooks/useFilterConfig");
|
||||
|
||||
import { useFilterConfig } from "../../../hooks/useFilterConfig";
|
||||
|
||||
const mockUseFilterConfig = vi.mocked(useFilterConfig);
|
||||
|
||||
const mockConfig: FilterConfig = {
|
||||
name: "sshd",
|
||||
filename: "sshd.conf",
|
||||
before: null,
|
||||
after: null,
|
||||
variables: {},
|
||||
prefregex: null,
|
||||
failregex: ["^<HOST> port \\d+"],
|
||||
ignoreregex: [],
|
||||
maxlines: null,
|
||||
datepattern: null,
|
||||
journalmatch: null,
|
||||
};
|
||||
|
||||
function renderForm(name: string) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<FilterForm name={name} />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FilterForm", () => {
|
||||
it("shows skeleton while loading", () => {
|
||||
mockUseFilterConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("sshd");
|
||||
// aria-label on Skeleton container
|
||||
expect(screen.getByLabelText(/loading sshd/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when loading fails", () => {
|
||||
mockUseFilterConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("sshd");
|
||||
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows fallback error when config is null with no error message", () => {
|
||||
mockUseFilterConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("sshd");
|
||||
expect(screen.getByText(/failed to load filter config/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders form accordion sections when config is loaded", () => {
|
||||
mockUseFilterConfig.mockReturnValue({
|
||||
config: mockConfig,
|
||||
loading: false,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("sshd");
|
||||
// Accordion headers should be visible
|
||||
expect(screen.getByText(/includes/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/definition/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the filter name to useFilterConfig", () => {
|
||||
mockUseFilterConfig.mockReturnValue({
|
||||
config: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
saving: false,
|
||||
saveError: null,
|
||||
refresh: vi.fn(),
|
||||
save: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm("nginx");
|
||||
expect(mockUseFilterConfig).toHaveBeenCalledWith("nginx");
|
||||
});
|
||||
});
|
||||
186
frontend/src/components/config/configStyles.ts
Normal file
186
frontend/src/components/config/configStyles.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Shared makeStyles definitions for the config page and its components.
|
||||
*
|
||||
* All config tab components import `useConfigStyles` from this module
|
||||
* so that visual changes need updating in only one place.
|
||||
*/
|
||||
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
export const useConfigStyles = makeStyles({
|
||||
page: {
|
||||
padding: tokens.spacingVerticalXXL,
|
||||
maxWidth: "1100px",
|
||||
},
|
||||
header: {
|
||||
marginBottom: tokens.spacingVerticalL,
|
||||
},
|
||||
tabContent: {
|
||||
marginTop: tokens.spacingVerticalL,
|
||||
animationName: "fadeInUp",
|
||||
animationDuration: tokens.durationNormal,
|
||||
animationTimingFunction: tokens.curveDecelerateMid,
|
||||
animationFillMode: "both",
|
||||
},
|
||||
section: {
|
||||
marginBottom: tokens.spacingVerticalXL,
|
||||
},
|
||||
/** Card container for form sections — adds visual separation and depth. */
|
||||
sectionCard: {
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
|
||||
boxShadow: tokens.shadow4,
|
||||
marginBottom: tokens.spacingVerticalS,
|
||||
},
|
||||
/** Label row at the top of a sectionCard. */
|
||||
sectionCardHeader: {
|
||||
color: tokens.colorNeutralForeground2,
|
||||
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
paddingBottom: tokens.spacingVerticalS,
|
||||
marginBottom: tokens.spacingVerticalM,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
},
|
||||
/** Monospace input with left brand-colour accent bar. */
|
||||
codeInput: {
|
||||
fontFamily: "monospace",
|
||||
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
|
||||
},
|
||||
/** Applied to AccordionItem wrappers to get a hover background on headers. */
|
||||
accordionItem: {
|
||||
"& button:hover": {
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
},
|
||||
},
|
||||
/** Applied to AccordionItem wrappers that are currently expanded. */
|
||||
accordionItemOpen: {
|
||||
borderLeft: `3px solid ${tokens.colorBrandBackground}`,
|
||||
},
|
||||
fieldRow: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
marginBottom: tokens.spacingVerticalS,
|
||||
"@media (max-width: 900px)": {
|
||||
gridTemplateColumns: "1fr",
|
||||
},
|
||||
},
|
||||
fieldRowThree: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
marginBottom: tokens.spacingVerticalS,
|
||||
"@media (max-width: 900px)": {
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
},
|
||||
"@media (max-width: 700px)": {
|
||||
gridTemplateColumns: "1fr",
|
||||
},
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
marginTop: tokens.spacingVerticalM,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
codeFont: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.85rem",
|
||||
},
|
||||
regexItem: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
marginBottom: tokens.spacingVerticalXS,
|
||||
},
|
||||
regexInput: {
|
||||
flexGrow: "1",
|
||||
fontFamily: "monospace",
|
||||
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
|
||||
},
|
||||
logLine: {
|
||||
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
marginBottom: tokens.spacingVerticalXXS,
|
||||
wordBreak: "break-all",
|
||||
},
|
||||
matched: {
|
||||
backgroundColor: tokens.colorPaletteGreenBackground2,
|
||||
},
|
||||
notMatched: {
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
},
|
||||
previewArea: {
|
||||
maxHeight: "400px",
|
||||
overflowY: "auto",
|
||||
padding: tokens.spacingHorizontalS,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
},
|
||||
infoText: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
/** Empty-state container: centred icon + message. */
|
||||
emptyState: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: tokens.spacingVerticalM,
|
||||
padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalL}`,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
textAlign: "center",
|
||||
},
|
||||
/** Auto-save status chip — for AutoSaveIndicator. */
|
||||
autoSaveWrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
color: tokens.colorNeutralForeground2,
|
||||
},
|
||||
autoSaveSaved: {
|
||||
opacity: "1",
|
||||
transform: "scale(1)",
|
||||
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
|
||||
},
|
||||
autoSaveFadingOut: {
|
||||
opacity: "0",
|
||||
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Global CSS keyframes injected once.
|
||||
*
|
||||
* ``makeStyles`` does not support top-level ``@keyframes``, so we inject them
|
||||
* via a ``<style>`` element on first import. The function is idempotent.
|
||||
*/
|
||||
export function injectGlobalStyles(): void {
|
||||
if (document.getElementById("bangui-global-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "bangui-global-styles";
|
||||
style.textContent = `
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes fadeInScale {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Inject keyframes on first module load (browser environment only).
|
||||
if (typeof window !== "undefined") {
|
||||
injectGlobalStyles();
|
||||
}
|
||||
28
frontend/src/components/config/index.ts
Normal file
28
frontend/src/components/config/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Barrel export for all config page components.
|
||||
*
|
||||
* Import from this module to access any component in the config package:
|
||||
* import { FiltersTab, RegexList } from "../components/config";
|
||||
*/
|
||||
|
||||
export { ActionsTab } from "./ActionsTab";
|
||||
export { ActionForm } from "./ActionForm";
|
||||
export type { ActionFormProps } from "./ActionForm";
|
||||
export { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator";
|
||||
export { ConfFilesTab } from "./ConfFilesTab";
|
||||
export type { ConfFilesTabProps } from "./ConfFilesTab";
|
||||
export { ExportTab } from "./ExportTab";
|
||||
export { FilterForm } from "./FilterForm";
|
||||
export type { FilterFormProps } from "./FilterForm";
|
||||
export { FiltersTab } from "./FiltersTab";
|
||||
export { GlobalTab } from "./GlobalTab";
|
||||
export { JailFilesTab } from "./JailFilesTab";
|
||||
export { JailFileForm } from "./JailFileForm";
|
||||
export { JailsTab } from "./JailsTab";
|
||||
export { MapTab } from "./MapTab";
|
||||
export { RegexList } from "./RegexList";
|
||||
export type { RegexListProps } from "./RegexList";
|
||||
export { RegexTesterTab } from "./RegexTesterTab";
|
||||
export { ServerTab } from "./ServerTab";
|
||||
export { useConfigStyles } from "./configStyles";
|
||||
144
frontend/src/hooks/__tests__/useAutoSave.test.ts
Normal file
144
frontend/src/hooks/__tests__/useAutoSave.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useAutoSave } from "../useAutoSave";
|
||||
|
||||
describe("useAutoSave", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts with idle status", () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useAutoSave("initial", save));
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.errorText).toBeNull();
|
||||
});
|
||||
|
||||
it("does not save immediately when value changes", () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const { rerender } = renderHook(({ value }) => useAutoSave(value, save), {
|
||||
initialProps: { value: "initial" },
|
||||
});
|
||||
|
||||
rerender({ value: "changed" });
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls save after debounce period", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const { rerender } = renderHook(({ value }) => useAutoSave(value, save, { debounceMs: 500 }), {
|
||||
initialProps: { value: "initial" },
|
||||
});
|
||||
|
||||
rerender({ value: "changed" });
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(500); });
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
expect(save).toHaveBeenCalledWith("changed");
|
||||
});
|
||||
|
||||
it("transitions to saving then saved on success", async () => {
|
||||
let resolveSave!: () => void;
|
||||
const save = vi.fn().mockImplementation(
|
||||
() => new Promise<void>((resolve) => { resolveSave = resolve; }),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
|
||||
{ initialProps: { value: "v1" } },
|
||||
);
|
||||
|
||||
rerender({ value: "v2" });
|
||||
act(() => { vi.advanceTimersByTime(100); });
|
||||
// Let the microtask queue run so the save call starts.
|
||||
await act(() => Promise.resolve());
|
||||
expect(result.current.status).toBe("saving");
|
||||
|
||||
await act(() => { resolveSave(); return Promise.resolve(); });
|
||||
expect(result.current.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("transitions to error status on save failure", async () => {
|
||||
const save = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
|
||||
{ initialProps: { value: "v1" } },
|
||||
);
|
||||
|
||||
rerender({ value: "v2" });
|
||||
act(() => { vi.advanceTimersByTime(100); });
|
||||
// Wait for the save promise to reject and state to update.
|
||||
await act(() => Promise.resolve());
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(result.current.errorText).toBe("network error");
|
||||
});
|
||||
|
||||
it("retry triggers another save attempt", async () => {
|
||||
const save = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("first failure"))
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ value }) => useAutoSave(value, save, { debounceMs: 100 }),
|
||||
{ initialProps: { value: "v1" } },
|
||||
);
|
||||
|
||||
rerender({ value: "v2" });
|
||||
act(() => { vi.advanceTimersByTime(100); });
|
||||
await act(() => Promise.resolve());
|
||||
await act(() => Promise.resolve());
|
||||
expect(result.current.status).toBe("error");
|
||||
|
||||
await act(() => { result.current.retry(); return Promise.resolve(); });
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
expect(result.current.status).toBe("saved");
|
||||
expect(save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("debounces rapid value changes — calls save only once", async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const { rerender } = renderHook(
|
||||
({ value }) => useAutoSave(value, save, { debounceMs: 300 }),
|
||||
{ initialProps: { value: "v1" } },
|
||||
);
|
||||
|
||||
rerender({ value: "v2" });
|
||||
vi.advanceTimersByTime(100);
|
||||
rerender({ value: "v3" });
|
||||
vi.advanceTimersByTime(100);
|
||||
rerender({ value: "v4" });
|
||||
|
||||
act(() => { vi.advanceTimersByTime(300); });
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith("v4");
|
||||
});
|
||||
|
||||
it("clears timers on unmount", () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ value }) => useAutoSave(value, save, { debounceMs: 500 }),
|
||||
{ initialProps: { value: "v1" } },
|
||||
);
|
||||
|
||||
rerender({ value: "v2" });
|
||||
unmount();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
64
frontend/src/pages/__tests__/ConfigPage.test.tsx
Normal file
64
frontend/src/pages/__tests__/ConfigPage.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ConfigPage } from "../ConfigPage";
|
||||
|
||||
// Mock all tab components to avoid deep render trees and API calls.
|
||||
vi.mock("../../components/config", () => ({
|
||||
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
|
||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||
GlobalTab: () => <div data-testid="global-tab">GlobalTab</div>,
|
||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||
MapTab: () => <div data-testid="map-tab">MapTab</div>,
|
||||
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
|
||||
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ConfigPage />
|
||||
</FluentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConfigPage", () => {
|
||||
it("renders the Jails tab by default", () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Filters tab when Filters tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
|
||||
expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("jails-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Actions tab when Actions tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /actions/i }));
|
||||
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Global tab when Global tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /global/i }));
|
||||
expect(screen.getByTestId("global-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Server tab when Server tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
|
||||
expect(screen.getByTestId("server-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the page heading", () => {
|
||||
renderPage();
|
||||
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user