/** * JailsTab — list/detail layout for per-jail configuration editing. * * Left pane: jail names with Active/Inactive badges, sorted with active on * top. Right pane: editable form for the selected jail plus a collapsible * raw-config editor. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge, Button, Combobox, Field, Input, MessageBar, MessageBarBody, Option, Select, Skeleton, SkeletonItem, Spinner, Switch, Text, tokens, } from "@fluentui/react-components"; import { Add24Regular, Dismiss24Regular, LockClosed24Regular, LockOpen24Regular, Play24Regular, } from "@fluentui/react-icons"; import { ApiError } from "../../api/client"; import { addLogPath, deactivateJail, deleteJailLocalOverride, deleteLogPath, fetchInactiveJails, fetchJailConfigFileContent, updateJailConfigFile, validateJailConfig, } from "../../api/config"; import type { AddLogPathRequest, ConfFileUpdateRequest, InactiveJail, JailConfig, JailConfigUpdate, JailValidationIssue, JailValidationResult, } from "../../types/config"; import { useAutoSave } from "../../hooks/useAutoSave"; import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { useJailConfigs } from "../../hooks/useConfig"; import { ActivateJailDialog } from "./ActivateJailDialog"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { ConfigListDetail } from "./ConfigListDetail"; import { CreateJailDialog } from "./CreateJailDialog"; import { RawConfigSection } from "./RawConfigSection"; import { RegexList } from "./RegexList"; import { useConfigStyles } from "./configStyles"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const BACKENDS = [ { value: "auto", label: "auto — pyinotify, then polling" }, { value: "polling", label: "polling — standard polling algorithm" }, { value: "pyinotify", label: "pyinotify — requires pyinotify library" }, { value: "systemd", label: "systemd — uses systemd journal" }, { value: "gamin", label: "gamin — legacy file alteration monitor" }, ] as const; const LOG_ENCODINGS = [ { value: "auto", label: "auto — use system locale" }, { value: "ascii", label: "ascii" }, { value: "utf-8", label: "utf-8" }, { value: "latin-1", label: "latin-1 (ISO 8859-1)" }, ] as const; const DATE_PATTERN_PRESETS = [ { value: "", label: "auto-detect (leave blank)" }, { value: "{^LN-BEG}", label: "{^LN-BEG} — line beginning" }, { value: "%%Y-%%m-%%d %%H:%%M:%%S", label: "YYYY-MM-DD HH:MM:SS" }, { value: "%%d/%%b/%%Y:%%H:%%M:%%S", label: "DD/Mon/YYYY:HH:MM:SS (Apache)" }, { value: "%%b %%d %%H:%%M:%%S", label: "Mon DD HH:MM:SS (syslog)" }, { value: "EPOCH", label: "EPOCH — Unix timestamp" }, { value: "%%Y-%%m-%%dT%%H:%%M:%%S", label: "ISO 8601" }, { value: "TAI64N", label: "TAI64N" }, ] as const; // --------------------------------------------------------------------------- // JailConfigDetail // --------------------------------------------------------------------------- interface JailConfigDetailProps { jail: JailConfig; onSave: (name: string, update: JailConfigUpdate) => Promise; onDeactivate?: () => void; /** When true all fields are read-only and auto-save is suppressed. */ readOnly?: boolean; /** When provided (and readOnly=true) shows an Activate Jail button. */ onActivate?: () => void; /** When provided (and readOnly=true) shows a Validate Config button. */ onValidate?: () => void; /** Whether validation is currently running (shows spinner on Validate button). */ validating?: boolean; } /** * Editable configuration form for a single fail2ban jail. * * Contains all config fields plus a collapsible raw-config editor at the * bottom. Previously known as JailAccordionPanel. * * @param props - Component props. * @returns JSX element. */ function JailConfigDetail({ jail, onSave, onDeactivate, readOnly = false, onActivate, onValidate, validating = false, }: JailConfigDetailProps): 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(jail.fail_regex); const [ignoreRegex, setIgnoreRegex] = useState(jail.ignore_regex); const [logPaths, setLogPaths] = useState(jail.log_paths); const [datePattern, setDatePattern] = useState(jail.date_pattern ?? ""); const [dnsMode, setDnsMode] = useState(jail.use_dns); const [backend, setBackend] = useState(jail.backend); const [logEncoding, setLogEncoding] = useState(jail.log_encoding); const [prefRegex, setPrefRegex] = useState(jail.prefregex); const [deletingPath, setDeletingPath] = useState(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( () => ({ 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, backend, log_encoding: logEncoding, 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, backend, logEncoding, prefRegex, escEnabled, escFactor, escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails, jail.ban_time, jail.find_time, jail.max_retry, ], ); const saveCurrent = useCallback( async (update: JailConfigUpdate): Promise => { if (readOnly) return; await onSave(jail.name, update); }, [jail.name, onSave, readOnly], ); const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = useAutoSave(autoSavePayload, saveCurrent); // Raw config file fetch/save helpers — uses jail.d/.conf convention. const fetchRaw = useCallback(async (): Promise => { const result = await fetchJailConfigFileContent(`${jail.name}.conf`); return result.content; }, [jail.name]); const saveRaw = useCallback( async (content: string): Promise => { const req: ConfFileUpdateRequest = { content }; await updateJailConfigFile(`${jail.name}.conf`, req); }, [jail.name], ); return (
{msg && ( {msg.text} )}
{ setBanTime(d.value); }} /> { setFindTime(d.value); }} /> { setMaxRetry(d.value); }} />
{ setDatePattern(d.optionValue ?? ""); }} onChange={(e) => { setDatePattern(e.target.value); }} > {DATE_PATTERN_PRESETS.map((p) => ( ))}
{ setPrefRegex(d.value); }} /> {logPaths.length === 0 ? ( (none) ) : ( logPaths.map((p, i) => (
{ setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v))); }} /> {!readOnly && (
)) )} {/* Add log path inline form — hidden in read-only mode */} {!readOnly && (
{ setNewLogPath(d.value); }} /> { setNewLogPathTail(d.checked); }} />
)}
{jail.actions.length > 0 && (
{jail.actions.map((a) => ( {a} ))}
)} {/* Ban-time Escalation */}
Ban-time Escalation { setEscEnabled(d.checked); }} /> {escEnabled && (
{ setEscFactor(d.value); }} /> { setEscMaxTime(d.value); }} /> { setEscRndTime(d.value); }} />
{ setEscFormula(d.value); }} /> { setEscMultipliers(d.value); }} /> { setEscOverallJails(d.checked); }} />
)}
{!readOnly && (
)} {!readOnly && onDeactivate !== undefined && (
)} {readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
{onValidate !== undefined && ( )} {onDeactivate !== undefined && ( )} {onActivate !== undefined && ( )}
)} {/* Raw Configuration — hidden in read-only (inactive jail) mode */} {!readOnly && (
)}
); } // --------------------------------------------------------------------------- // InactiveJailDetail // --------------------------------------------------------------------------- interface InactiveJailDetailProps { jail: InactiveJail; onActivate: () => void; /** Called when the user requests removal of the .local override file. */ onDeactivate?: () => void; } /** * Detail view for an inactive jail. * * Maps the parsed config fields to a JailConfig-compatible object and renders * JailConfigDetail in read-only mode, so the UI is identical to the active * jail view but with all fields disabled and an Activate button instead of * a Deactivate button. * * @param props - Component props. * @returns JSX element. */ function InactiveJailDetail({ jail, onActivate, onDeactivate, }: InactiveJailDetailProps): React.JSX.Element { const styles = useConfigStyles(); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); const handleValidate = useCallback((): void => { setValidating(true); setValidationResult(null); validateJailConfig(jail.name) .then((result) => { setValidationResult(result); }) .catch(() => { /* validation call failed — ignore */ }) .finally(() => { setValidating(false); }); }, [jail.name]); const blockingIssues: JailValidationIssue[] = validationResult?.issues.filter((i) => i.field !== "logpath") ?? []; const advisoryIssues: JailValidationIssue[] = validationResult?.issues.filter((i) => i.field === "logpath") ?? []; const jailConfig = useMemo( () => ({ name: jail.name, ban_time: jail.ban_time_seconds, find_time: jail.find_time_seconds, max_retry: jail.maxretry ?? 5, fail_regex: jail.fail_regex, ignore_regex: jail.ignore_regex, log_paths: jail.logpath, date_pattern: jail.date_pattern, log_encoding: jail.log_encoding, backend: jail.backend, use_dns: jail.use_dns, prefregex: jail.prefregex, actions: jail.actions, bantime_escalation: jail.bantime_escalation, }), [jail], ); return (
{/* Validation result panel */} {validationResult !== null && (
{blockingIssues.length === 0 && advisoryIssues.length === 0 ? ( Configuration is valid. ) : null} {blockingIssues.length > 0 && ( Errors:
    {blockingIssues.map((issue, idx) => (
  • {issue.field}: {issue.message}
  • ))}
)} {advisoryIssues.length > 0 && ( Warnings:
    {advisoryIssues.map((issue, idx) => (
  • {issue.field}: {issue.message}
  • ))}
)}
)} { /* read-only — never called */ }} readOnly onActivate={onActivate} onDeactivate={jail.has_local_override ? onDeactivate : undefined} onValidate={handleValidate} validating={validating} />
); } // --------------------------------------------------------------------------- // JailsTab // --------------------------------------------------------------------------- /** * Tab component showing all fail2ban jails in a list/detail layout with * editable configuration forms. * * @returns JSX element. */ export function JailsTab(): React.JSX.Element { const styles = useConfigStyles(); const { jails, loading, error, refresh, updateJail } = useJailConfigs(); const { activeJails } = useConfigActiveStatus(); const [selectedName, setSelectedName] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); // Inactive jails const [inactiveJails, setInactiveJails] = useState([]); const [inactiveLoading, setInactiveLoading] = useState(false); const [activateTarget, setActivateTarget] = useState(null); const loadInactive = useCallback((): void => { setInactiveLoading(true); fetchInactiveJails() .then((res) => { setInactiveJails(res.jails); }) .catch(() => { /* non-critical — active-only view still works */ }) .finally(() => { setInactiveLoading(false); }); }, []); useEffect(() => { loadInactive(); }, [loadInactive]); const handleDeactivate = useCallback((name: string): void => { deactivateJail(name) .then(() => { setSelectedName(null); refresh(); loadInactive(); }) .catch(() => { /* non-critical — list refreshes on next load */ }); }, [refresh, loadInactive]); const handleDeactivateInactive = useCallback((name: string): void => { deleteJailLocalOverride(name) .then(() => { setSelectedName(null); loadInactive(); }) .catch(() => { /* non-critical — list refreshes on next load */ }); }, [loadInactive]); const handleActivated = useCallback((): void => { setActivateTarget(null); setSelectedName(null); refresh(); loadInactive(); }, [refresh, loadInactive]); /** Unified list items: active jails first (from useJailConfigs), then inactive. */ const listItems = useMemo>(() => { const activeItems = jails.map((j) => ({ name: j.name, kind: "active" as const })); const activeNames = new Set(jails.map((j) => j.name)); const inactiveItems = inactiveJails .filter((j) => !activeNames.has(j.name)) .map((j) => ({ name: j.name, kind: "inactive" as const })); return [...activeItems, ...inactiveItems]; }, [jails, inactiveJails]); const activeJailMap = useMemo( () => new Map(jails.map((j) => [j.name, j])), [jails], ); const inactiveJailMap = useMemo( () => new Map(inactiveJails.map((j) => [j.name, j])), [inactiveJails], ); const selectedListItem = listItems.find((item) => item.name === selectedName); const selectedActiveJail = selectedListItem?.kind === "active" ? activeJailMap.get(selectedListItem.name) : undefined; const selectedInactiveJail = selectedListItem?.kind === "inactive" ? inactiveJailMap.get(selectedListItem.name) : undefined; if (loading && listItems.length === 0) { return ( {[0, 1, 2].map((i) => ( ))} ); } if (error) { return ( {error} ); } if (listItems.length === 0 && !loading && !inactiveLoading) { return (
No jails found. Ensure fail2ban is running and jails are configured.
); } const listHeader = ( ); return (
item.kind === "active" && activeJails.has(item.name)} selectedName={selectedName} onSelect={setSelectedName} loading={false} error={null} listHeader={listHeader} > {selectedActiveJail !== undefined ? ( { handleDeactivate(selectedActiveJail.name); }} /> ) : selectedInactiveJail !== undefined ? ( { setActivateTarget(selectedInactiveJail); }} onDeactivate={ selectedInactiveJail.has_local_override ? (): void => { handleDeactivateInactive(selectedInactiveJail.name); } : undefined } /> ) : null}
{ setActivateTarget(null); }} onActivated={handleActivated} /> { setCreateDialogOpen(false); }} onCreated={() => { setCreateDialogOpen(false); refresh(); loadInactive(); }} />
); }