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
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
tokens,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
fetchInactiveJails,
|
||||
fetchJailConfigFileContent,
|
||||
updateJailConfigFile,
|
||||
validateJailConfig,
|
||||
} from "../../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
@@ -45,6 +47,8 @@ import type {
|
||||
InactiveJail,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
JailValidationIssue,
|
||||
JailValidationResult,
|
||||
} from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
||||
@@ -99,6 +103,10 @@ interface JailConfigDetailProps {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +124,8 @@ function JailConfigDetail({
|
||||
onDeactivate,
|
||||
readOnly = false,
|
||||
onActivate,
|
||||
onValidate,
|
||||
validating = false,
|
||||
}: JailConfigDetailProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [banTime, setBanTime] = useState(String(jail.ban_time));
|
||||
@@ -563,15 +573,27 @@ function JailConfigDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readOnly && onActivate !== undefined && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Play24Regular />}
|
||||
onClick={onActivate}
|
||||
>
|
||||
Activate Jail
|
||||
</Button>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -596,6 +618,8 @@ function JailConfigDetail({
|
||||
interface InactiveJailDetailProps {
|
||||
jail: InactiveJail;
|
||||
onActivate: () => void;
|
||||
/** Whether to show and call onCrashDetected on activation crash. */
|
||||
onCrashDetected?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,6 +638,22 @@ function InactiveJailDetail({
|
||||
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>(
|
||||
() => ({
|
||||
@@ -648,11 +688,49 @@ function InactiveJailDetail({
|
||||
<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>
|
||||
);
|
||||
@@ -668,7 +746,12 @@ function InactiveJailDetail({
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function JailsTab(): React.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();
|
||||
@@ -807,6 +890,7 @@ export function JailsTab(): React.JSX.Element {
|
||||
<InactiveJailDetail
|
||||
jail={selectedInactiveJail}
|
||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
) : null}
|
||||
</ConfigListDetail>
|
||||
@@ -817,6 +901,7 @@ export function JailsTab(): React.JSX.Element {
|
||||
open={activateTarget !== null}
|
||||
onClose={() => { setActivateTarget(null); }}
|
||||
onActivated={handleActivated}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
|
||||
<CreateJailDialog
|
||||
|
||||
Reference in New Issue
Block a user