- 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
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
/**
|
|
* RecoveryBanner — full-width warning shown when fail2ban stopped responding
|
|
* shortly after a jail was activated (indicating the new jail config may be
|
|
* invalid).
|
|
*
|
|
* Polls ``GET /api/config/pending-recovery`` every 10 seconds and renders a
|
|
* dismissible ``MessageBar`` when an unresolved crash record is present.
|
|
* The "Disable & Restart" button calls the rollback endpoint to disable the
|
|
* offending jail and attempt to restart fail2ban.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
Button,
|
|
MessageBar,
|
|
MessageBarActions,
|
|
MessageBarBody,
|
|
MessageBarTitle,
|
|
Spinner,
|
|
tokens,
|
|
} from "@fluentui/react-components";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { fetchPendingRecovery, rollbackJail } from "../../api/config";
|
|
import type { PendingRecovery } from "../../types/config";
|
|
|
|
const POLL_INTERVAL_MS = 10_000;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Recovery banner that polls for pending crash-recovery records.
|
|
*
|
|
* Mount this once at the layout level so it is visible across all pages
|
|
* while a recovery is pending.
|
|
*
|
|
* @returns A MessageBar element, or null when nothing is pending.
|
|
*/
|
|
export function RecoveryBanner(): React.JSX.Element | null {
|
|
const navigate = useNavigate();
|
|
const [pending, setPending] = useState<PendingRecovery | null>(null);
|
|
const [rolling, setRolling] = useState(false);
|
|
const [rollbackError, setRollbackError] = useState<string | null>(null);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const poll = useCallback((): void => {
|
|
fetchPendingRecovery()
|
|
.then((record) => {
|
|
// Hide the banner once fail2ban has recovered on its own.
|
|
if (record?.recovered) {
|
|
setPending(null);
|
|
} else {
|
|
setPending(record);
|
|
}
|
|
})
|
|
.catch(() => { /* ignore network errors — will retry */ });
|
|
}, []);
|
|
|
|
// Start polling on mount.
|
|
useEffect(() => {
|
|
poll();
|
|
timerRef.current = setInterval(poll, POLL_INTERVAL_MS);
|
|
return (): void => {
|
|
if (timerRef.current !== null) clearInterval(timerRef.current);
|
|
};
|
|
}, [poll]);
|
|
|
|
const handleRollback = useCallback((): void => {
|
|
if (!pending || rolling) return;
|
|
setRolling(true);
|
|
setRollbackError(null);
|
|
rollbackJail(pending.jail_name)
|
|
.then(() => {
|
|
setPending(null);
|
|
})
|
|
.catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setRollbackError(msg);
|
|
})
|
|
.finally(() => {
|
|
setRolling(false);
|
|
});
|
|
}, [pending, rolling]);
|
|
|
|
const handleViewDetails = useCallback((): void => {
|
|
navigate("/config");
|
|
}, [navigate]);
|
|
|
|
if (pending === null) return null;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
flexShrink: 0,
|
|
paddingLeft: tokens.spacingHorizontalM,
|
|
paddingRight: tokens.spacingHorizontalM,
|
|
paddingTop: tokens.spacingVerticalXS,
|
|
paddingBottom: tokens.spacingVerticalXS,
|
|
}}
|
|
role="alert"
|
|
>
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>
|
|
<MessageBarTitle>fail2ban Stopped After Jail Activation</MessageBarTitle>
|
|
fail2ban stopped responding after activating jail{" "}
|
|
<strong>{pending.jail_name}</strong>. The jail's configuration
|
|
may be invalid.
|
|
{rollbackError && (
|
|
<div style={{ marginTop: tokens.spacingVerticalXS, color: tokens.colorStatusDangerForeground1 }}>
|
|
Rollback failed: {rollbackError}
|
|
</div>
|
|
)}
|
|
</MessageBarBody>
|
|
<MessageBarActions>
|
|
<Button
|
|
appearance="primary"
|
|
size="small"
|
|
icon={rolling ? <Spinner size="tiny" /> : undefined}
|
|
disabled={rolling}
|
|
onClick={handleRollback}
|
|
>
|
|
{rolling ? "Disabling…" : "Disable & Restart"}
|
|
</Button>
|
|
<Button
|
|
appearance="secondary"
|
|
size="small"
|
|
onClick={handleViewDetails}
|
|
>
|
|
View Logs
|
|
</Button>
|
|
</MessageBarActions>
|
|
</MessageBar>
|
|
</div>
|
|
);
|
|
}
|