Files
BanGUI/frontend/src/components/config/JailsTab.tsx
Lukas 3024a4ef07 fix(config): re-sync JailConfigDetail form when jail prop updates from server refresh
When useJailConfigs performs a background refresh, it may deliver an updated
JailConfig object for an already-selected jail. Previously, JailConfigDetail
would continue displaying stale locally-edited form values because the component
only re-initialized on jail name changes (via the key prop), not on object
identity changes.

Added a useEffect that detects when the jail prop reference has changed
(indicating a server refresh) and automatically resets all form fields to the
new server state, but only if autoSave is idle and has no pending changes.
This prevents accidentally overwriting external changes when the user saves,
while still letting users continue editing unsaved changes without interruption.

The implementation:
- Tracks the last-synced jail object in a ref
- Compares incoming jail reference to detect server updates
- Checks autoSave status to ensure no pending saves
- Verifies that current form state matches the old jail values
- Resets all 20+ form fields when conditions are met

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:08:33 +02:00

1059 lines
35 KiB
TypeScript

/**
* 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, useRef, 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 { handleFetchError } from "../../utils/fetchError";
import type {
AddLogPathRequest,
BackendType,
DNSMode,
InactiveJail,
JailConfig,
JailConfigUpdate,
JailValidationIssue,
JailValidationResult,
LogEncoding,
} from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
import { useJailConfigs } from "../../hooks/useJailConfigs";
import { useJailAdmin } from "../../hooks/useJailAdmin";
import { useJailConfigOperations } from "../../hooks/useJailConfigOperations";
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<void>;
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<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<DNSMode>(jail.use_dns);
const [backend, setBackend] = useState<BackendType>(jail.backend);
const [logEncoding, setLogEncoding] = useState<LogEncoding>(jail.log_encoding);
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 {
addLogPath: addLogPathToJail,
deleteLogPath: deleteJailLogPath,
fetchRawContent,
saveRawContent,
} = useJailConfigOperations(jail.name);
const handleDeleteLogPath = useCallback(
async (path: string) => {
setDeletingPath(path);
setMsg(null);
try {
await deleteJailLogPath(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);
}
},
[deleteJailLogPath],
);
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 addLogPathToJail(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);
}
}, [addLogPathToJail, newLogPath, newLogPathTail]);
const autoSavePayload = useMemo<JailConfigUpdate>(
() => ({
ban_time: banTime.trim() === "" || Number.isNaN(Number(banTime)) ? jail.ban_time : Number(banTime),
find_time: findTime.trim() === "" || Number.isNaN(Number(findTime)) ? jail.find_time : Number(findTime),
max_retry: maxRetry.trim() === "" || Number.isNaN(Number(maxRetry)) ? jail.max_retry : Number(maxRetry),
fail_regex: failRegex,
ignore_regex: ignoreRegex,
date_pattern: datePattern !== "" ? datePattern : null,
dns_mode: dnsMode,
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, 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<void> => {
if (readOnly) return;
await onSave(jail.name, update);
},
[jail.name, onSave, readOnly],
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(autoSavePayload, saveCurrent);
// Ref to track the last jail object we synced from. When the incoming jail
// prop is a new object (indicating a server refresh), and autoSave is idle
// with no pending changes, we reset the form to the new server state.
const lastSyncedJailRef = useRef<JailConfig>(jail);
// Resync form fields when the jail prop changes identity (server refresh)
// but only if autoSave has completed and there are no pending changes.
useEffect(() => {
if (
lastSyncedJailRef.current !== jail &&
saveStatus === "idle" &&
!saveErrorText
) {
// Payload is equal to the server state, so we can safely reset.
if (
JSON.stringify(autoSavePayload) === JSON.stringify({
ban_time: lastSyncedJailRef.current.ban_time,
find_time: lastSyncedJailRef.current.find_time,
max_retry: lastSyncedJailRef.current.max_retry,
fail_regex: lastSyncedJailRef.current.fail_regex,
ignore_regex: lastSyncedJailRef.current.ignore_regex,
date_pattern: lastSyncedJailRef.current.date_pattern ?? null,
dns_mode: lastSyncedJailRef.current.use_dns,
log_encoding: lastSyncedJailRef.current.log_encoding,
prefregex: lastSyncedJailRef.current.prefregex,
bantime_escalation: {
increment: lastSyncedJailRef.current.bantime_escalation?.increment ?? false,
factor: lastSyncedJailRef.current.bantime_escalation?.factor ?? null,
formula: lastSyncedJailRef.current.bantime_escalation?.formula ?? null,
multipliers: lastSyncedJailRef.current.bantime_escalation?.multipliers ?? null,
max_time: lastSyncedJailRef.current.bantime_escalation?.max_time ?? null,
rnd_time: lastSyncedJailRef.current.bantime_escalation?.rnd_time ?? null,
overall_jails: lastSyncedJailRef.current.bantime_escalation?.overall_jails ?? false,
},
})
) {
// Reset all form state to new jail prop values.
setBanTime(String(jail.ban_time));
setFindTime(String(jail.find_time));
setMaxRetry(String(jail.max_retry));
setFailRegex(jail.fail_regex);
setIgnoreRegex(jail.ignore_regex);
setLogPaths(jail.log_paths);
setDatePattern(jail.date_pattern ?? "");
setDnsMode(jail.use_dns);
setBackend(jail.backend);
setLogEncoding(jail.log_encoding);
setPrefRegex(jail.prefregex);
setEscEnabled(jail.bantime_escalation?.increment ?? false);
setEscFactor(
jail.bantime_escalation?.factor != null
? String(jail.bantime_escalation.factor)
: "",
);
setEscFormula(jail.bantime_escalation?.formula ?? "");
setEscMultipliers(jail.bantime_escalation?.multipliers ?? "");
setEscMaxTime(
jail.bantime_escalation?.max_time != null
? String(jail.bantime_escalation.max_time)
: "",
);
setEscRndTime(
jail.bantime_escalation?.rnd_time != null
? String(jail.bantime_escalation.rnd_time)
: "",
);
setEscOverallJails(jail.bantime_escalation?.overall_jails ?? false);
}
lastSyncedJailRef.current = jail;
}
}, [jail, saveStatus, saveErrorText, autoSavePayload]);
// Raw config file fetch/save helpers — uses jail.d/<name>.conf convention.
const fetchRaw = useCallback(async (): Promise<string> => {
return await fetchRawContent();
}, [fetchRawContent]);
const saveRaw = useCallback(
async (content: string): Promise<void> => {
await saveRawContent(content);
},
[saveRawContent],
);
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}
readOnly={readOnly}
onChange={(_e, d) => {
setBanTime(d.value);
}}
/>
</Field>
<Field label="Find Time (s)">
<Input
type="number"
value={findTime}
readOnly={readOnly}
onChange={(_e, d) => {
setFindTime(d.value);
}}
/>
</Field>
<Field label="Max Retry">
<Input
type="number"
value={maxRetry}
readOnly={readOnly}
onChange={(_e, d) => {
setMaxRetry(d.value);
}}
/>
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="Backend">
<Select
value={backend}
disabled={readOnly}
onChange={(_e, d) => {
setBackend(d.value as BackendType);
}}
>
{BACKENDS.map((b) => (
<option key={b.value} value={b.value}>{b.label}</option>
))}
</Select>
</Field>
<Field label="Log Encoding">
<Select
value={logEncoding.toLowerCase()}
disabled={readOnly}
onChange={(_e, d) => {
setLogEncoding(d.value as LogEncoding);
}}
>
{LOG_ENCODINGS.map((e) => (
<option key={e.value} value={e.value}>{e.label}</option>
))}
</Select>
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="Date Pattern" hint="Leave blank for auto-detect.">
<Combobox
className={styles.codeFont}
placeholder="auto-detect"
value={datePattern}
selectedOptions={[datePattern]}
freeform
disabled={readOnly}
onOptionSelect={(_e, d) => {
setDatePattern(d.optionValue ?? "");
}}
onChange={(e) => {
setDatePattern(e.target.value);
}}
>
{DATE_PATTERN_PRESETS.map((p) => (
<Option key={p.value} value={p.value}>{p.label}</Option>
))}
</Combobox>
</Field>
<Field label="DNS Mode" hint={"\u00A0"}>
<Select
value={dnsMode}
disabled={readOnly}
onChange={(_e, d) => {
setDnsMode(d.value as DNSMode);
}}
>
<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}
readOnly={readOnly}
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, i) => (
<div key={p} className={styles.regexItem}>
<Input
className={styles.codeFont}
style={{ flexGrow: 1 }}
value={p}
readOnly={readOnly}
onChange={(_e, d) => {
setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v)));
}}
/>
{!readOnly && (
<Button
appearance="subtle"
icon={<Dismiss24Regular />}
size="small"
disabled={deletingPath === p}
title="Remove log path"
onClick={() => void handleDeleteLogPath(p)}
/>
)}
</div>
))
)}
{/* Add log path inline form — hidden in read-only mode */}
{!readOnly && (
<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}
readOnly={readOnly}
/>
</div>
<div style={{ marginTop: tokens.spacingVerticalS }}>
<RegexList
label="Ignore Regex"
patterns={ignoreRegex}
onChange={setIgnoreRegex}
readOnly={readOnly}
/>
</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}
disabled={readOnly}
onChange={(_e, d) => {
setEscEnabled(d.checked);
}}
/>
{escEnabled && (
<div>
<div className={styles.fieldRowThree}>
<Field label="Factor">
<Input
type="number"
value={escFactor}
readOnly={readOnly}
onChange={(_e, d) => {
setEscFactor(d.value);
}}
/>
</Field>
<Field label="Max Time (s)">
<Input
type="number"
value={escMaxTime}
readOnly={readOnly}
onChange={(_e, d) => {
setEscMaxTime(d.value);
}}
/>
</Field>
<Field label="Random Jitter (s)">
<Input
type="number"
value={escRndTime}
readOnly={readOnly}
onChange={(_e, d) => {
setEscRndTime(d.value);
}}
/>
</Field>
</div>
<Field label="Formula">
<Input
value={escFormula}
readOnly={readOnly}
onChange={(_e, d) => {
setEscFormula(d.value);
}}
/>
</Field>
<Field label="Multipliers (space-separated)">
<Input
value={escMultipliers}
readOnly={readOnly}
onChange={(_e, d) => {
setEscMultipliers(d.value);
}}
/>
</Field>
<Switch
label="Count repeat offences across all jails"
checked={escOverallJails}
disabled={readOnly}
onChange={(_e, d) => {
setEscOverallJails(d.checked);
}}
/>
</div>
)}
</div>
{!readOnly && (
<div style={{ marginTop: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
)}
{!readOnly && onDeactivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="secondary"
icon={<LockOpen24Regular />}
onClick={onDeactivate}
>
Deactivate Jail
</Button>
</div>
)}
{readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
{onValidate !== undefined && (
<Button
appearance="secondary"
icon={validating ? <Spinner size="tiny" /> : undefined}
onClick={onValidate}
disabled={validating}
>
{validating ? "Validating…" : "Validate Config"}
</Button>
)}
{onDeactivate !== undefined && (
<Button
appearance="secondary"
icon={<LockOpen24Regular />}
onClick={onDeactivate}
>
Deactivate Jail
</Button>
)}
{onActivate !== undefined && (
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
)}
</div>
)}
{/* Raw Configuration — hidden in read-only (inactive jail) mode */}
{!readOnly && (
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
label="Raw Jail Configuration"
/>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// InactiveJailDetail
// ---------------------------------------------------------------------------
interface InactiveJailDetailProps {
jail: InactiveJail;
onActivate: () => void;
/** Called when the user requests removal of the .local override file. */
onDeactivate?: () => void;
onValidate: () => Promise<JailValidationResult>;
}
/**
* 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,
onValidate,
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<JailValidationResult | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const handleValidate = useCallback((): void => {
setValidating(true);
setValidationResult(null);
setValidationError(null);
onValidate()
.then((result) => { setValidationResult(result); })
.catch((err: unknown) => {
handleFetchError(err, setValidationError, "Validation request failed.");
})
.finally(() => { setValidating(false); });
}, [onValidate]);
const blockingIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field !== "logpath") ?? [];
const advisoryIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field === "logpath") ?? [];
const jailConfig = useMemo<JailConfig>(
() => ({
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 (
<div>
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
<Field label="Filter">
<Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} />
</Field>
<Field label="Port">
<Input readOnly value={jail.port ?? "(auto)"} />
</Field>
</div>
<Field label="Source file" style={{ marginBottom: tokens.spacingVerticalM }}>
<Input readOnly value={jail.source_file} className={styles.codeFont} />
</Field>
{/* Validation error panel */}
{validationError !== null && (
<div style={{ marginBottom: tokens.spacingVerticalM }}>
<MessageBar intent="error">
<MessageBarBody>{validationError}</MessageBarBody>
</MessageBar>
</div>
)}
{/* Validation result panel */}
{validationResult !== null && validationError === null && (
<div style={{ marginBottom: tokens.spacingVerticalM }}>
{blockingIssues.length === 0 && advisoryIssues.length === 0 ? (
<MessageBar intent="success">
<MessageBarBody>Configuration is valid.</MessageBarBody>
</MessageBar>
) : null}
{blockingIssues.length > 0 && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalXS }}>
<MessageBarBody>
<strong>Errors:</strong>
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
{blockingIssues.map((issue, idx) => (
<li key={`${issue.field}-${String(idx)}`}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
{advisoryIssues.length > 0 && (
<MessageBar intent="warning">
<MessageBarBody>
<strong>Warnings:</strong>
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
{advisoryIssues.map((issue, idx) => (
<li key={`${issue.field}-${String(idx)}`}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
</div>
)}
<JailConfigDetail
jail={jailConfig}
onSave={async () => { /* read-only — never called */ }}
readOnly
onActivate={onActivate}
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
onValidate={handleValidate}
validating={validating}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// JailsTab
// ---------------------------------------------------------------------------
/**
* Tab component showing all fail2ban jails in a list/detail layout with
* editable configuration forms.
*
* @returns JSX element.
*/
interface JailsTabProps {
/** Jail name to pre-select when the component mounts. */
initialJail?: string;
}
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail } =
useJailConfigs();
const { activeJails } = useConfigActiveStatus();
const {
inactiveJails,
inactiveLoading,
refreshInactiveJails,
deactivateJail,
deleteJailLocalOverride,
validateJailConfig,
activateJail,
createJailConfigFile,
} = useJailAdmin();
const [selectedName, setSelectedName] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
const handleDeactivate = useCallback(
async (name: string): Promise<void> => {
try {
await deactivateJail(name);
setSelectedName(null);
refresh();
refreshInactiveJails();
} catch {
/* non-critical — list refreshes on next load */ }
},
[deactivateJail, refresh, refreshInactiveJails],
);
const handleDeactivateInactive = useCallback(
async (name: string): Promise<void> => {
try {
await deleteJailLocalOverride(name);
setSelectedName(null);
refreshInactiveJails();
} catch {
/* non-critical — list refreshes on next load */ }
},
[deleteJailLocalOverride, refreshInactiveJails],
);
const handleActivated = useCallback((): void => {
setActivateTarget(null);
setSelectedName(null);
refresh();
refreshInactiveJails();
}, [refresh, refreshInactiveJails]);
const listItems = useMemo<Array<{ name: string; kind: "active" | "inactive" }>>(() => {
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]);
useEffect(() => {
if (!initialJail || selectedName) return;
if (listItems.some((item) => item.name === initialJail)) {
setSelectedName(initialJail);
}
}, [initialJail, listItems, selectedName]);
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 (
<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>
);
}
if (listItems.length === 0 && !loading && !inactiveLoading) {
return (
<div className={styles.emptyState}>
<LockClosed24Regular
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
aria-hidden
/>
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
No jails found.
</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
Ensure fail2ban is running and jails are configured.
</Text>
</div>
);
}
const listHeader = (
<Button
appearance="outline"
icon={<Add24Regular />}
size="small"
onClick={() => { setCreateDialogOpen(true); }}
>
Create Config
</Button>
);
return (
<div className={styles.tabContent}>
<div style={{ marginTop: tokens.spacingVerticalM }}>
<ConfigListDetail
items={listItems}
isActive={(item) => item.kind === "active" && activeJails.has(item.name)}
selectedName={selectedName}
onSelect={setSelectedName}
loading={false}
error={null}
listHeader={listHeader}
>
{selectedActiveJail !== undefined ? (
<JailConfigDetail
key={selectedActiveJail.name}
jail={selectedActiveJail}
onSave={updateJail}
onDeactivate={() => { void handleDeactivate(selectedActiveJail.name); }}
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
key={selectedInactiveJail.name}
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
onDeactivate={
selectedInactiveJail.has_local_override
? (): void => { void handleDeactivateInactive(selectedInactiveJail.name); }
: undefined
}
onValidate={async () => validateJailConfig(selectedInactiveJail.name)}
/>
) : null}
</ConfigListDetail>
</div>
<ActivateJailDialog
jail={activateTarget}
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
onValidate={async () => {
if (!activateTarget) {
return { jail_name: "", valid: false, issues: [] };
}
return await validateJailConfig(activateTarget.name);
}}
onActivate={async (payload) => {
if (!activateTarget) {
return {
name: "",
active: false,
message: "No jail selected.",
fail2ban_running: false,
validation_warnings: [],
};
}
return await activateJail(activateTarget.name, payload);
}}
/>
<CreateJailDialog
open={createDialogOpen}
onClose={() => { setCreateDialogOpen(false); }}
onCreateJail={async (payload) => {
await createJailConfigFile(payload);
}}
onCreated={() => {
setCreateDialogOpen(false);
refresh();
refreshInactiveJails();
}}
/>
</div>
);
}