Files
BanGUI/frontend/src/components/config/JailsTab.tsx
Lukas 0966f347c4 feat: Task 3 — invalid jail config recovery (pre-validation, crash detection, rollback)
- Backend: extend activate_jail() with pre-validation and 4-attempt post-reload
  health probe; add validate_jail_config() and rollback_jail() service functions
- Backend: new endpoints POST /api/config/jails/{name}/validate,
  GET /api/config/pending-recovery, POST /api/config/jails/{name}/rollback
- Backend: extend JailActivationResponse with fail2ban_running + validation_warnings;
  add JailValidationIssue, JailValidationResult, PendingRecovery, RollbackResponse models
- Backend: health_check task tracks last_activation and creates PendingRecovery
  record when fail2ban goes offline within 60 s of an activation
- Backend: add fail2ban_start_command setting (configurable start cmd for rollback)
- Frontend: ActivateJailDialog — pre-validation on open, crash-detected callback,
  extended spinner text during activation+verify
- Frontend: JailsTab — Validate Config button for inactive jails, validation
  result panels (blocking errors + advisory warnings)
- Frontend: RecoveryBanner component — polls pending-recovery, shows full-width
  alert with Disable & Restart / View Logs buttons
- Frontend: MainLayout — mount RecoveryBanner at layout level
- Tests: 19 new backend service tests (validate, rollback, filter/action parsing)
  + 6 health_check crash-detection tests + 11 router tests; 5 RecoveryBanner
  frontend tests; fix mock setup in existing activate_jail tests
2026-03-14 14:13:07 +01:00

919 lines
29 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, 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,
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<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(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<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,
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<void> => {
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/<name>.conf convention.
const fetchRaw = useCallback(async (): Promise<string> => {
const result = await fetchJailConfigFileContent(`${jail.name}.conf`);
return result.content;
}, [jail.name]);
const saveRaw = useCallback(
async (content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content };
await updateJailConfigFile(`${jail.name}.conf`, req);
},
[jail.name],
);
return (
<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);
}}
>
{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);
}}
>
{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);
}}
>
<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={i} 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) && (
<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>
)}
{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;
/** Whether to show and call onCrashDetected on activation crash. */
onCrashDetected?: () => 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,
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<JailValidationResult | null>(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<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 result panel */}
{validationResult !== 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={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={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
</div>
)}
<JailConfigDetail
jail={jailConfig}
onSave={async () => { /* read-only — never called */ }}
readOnly
onActivate={onActivate}
onValidate={handleValidate}
validating={validating}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// JailsTab
// ---------------------------------------------------------------------------
/**
* Tab component showing all fail2ban jails in a list/detail layout with
* editable configuration forms.
*
* @returns JSX element.
*/
export interface JailsTabProps {
/** Called when fail2ban stopped responding after a jail was activated. */
onCrashDetected?: () => void;
}
export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Element {
const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail } =
useJailConfigs();
const { activeJails } = useConfigActiveStatus();
const [selectedName, setSelectedName] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Inactive jails
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [inactiveLoading, setInactiveLoading] = useState(false);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(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 handleActivated = useCallback((): void => {
setActivateTarget(null);
setSelectedName(null);
refresh();
loadInactive();
}, [refresh, loadInactive]);
/** Unified list items: active jails first (from useJailConfigs), then inactive. */
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]);
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
jail={selectedActiveJail}
onSave={updateJail}
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
onCrashDetected={onCrashDetected}
/>
) : null}
</ConfigListDetail>
</div>
<ActivateJailDialog
jail={activateTarget}
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
onCrashDetected={onCrashDetected}
/>
<CreateJailDialog
open={createDialogOpen}
onClose={() => { setCreateDialogOpen(false); }}
onCreated={() => {
setCreateDialogOpen(false);
refresh();
loadInactive();
}}
/>
</div>
);
}