Files
BanGUI/frontend/src/components/common/RecoveryBanner.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

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&apos;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>
);
}