Remove RecoveryBanner component and dead onCrashDetected code

- Delete RecoveryBanner.tsx component and its test
- Remove RecoveryBanner from MainLayout
- Remove onCrashDetected prop from ActivateJailDialog, JailsTab
- Remove fetchPendingRecovery, rollbackJail API functions
- Remove configJailRollback, configPendingRecovery endpoints
- Remove PendingRecovery type
This commit is contained in:
2026-03-15 13:41:06 +01:00
parent d4d04491d2
commit 12f04bd8d6
4 changed files with 12 additions and 302 deletions

View File

@@ -1,136 +0,0 @@
/**
* 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>
);
}

View File

@@ -1,141 +0,0 @@
/**
* Tests for RecoveryBanner (Task 3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter } from "react-router-dom";
import { RecoveryBanner } from "../RecoveryBanner";
import type { PendingRecovery } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchPendingRecovery: vi.fn(),
rollbackJail: vi.fn(),
}));
import { fetchPendingRecovery, rollbackJail } from "../../../api/config";
const mockFetchPendingRecovery = vi.mocked(fetchPendingRecovery);
const mockRollbackJail = vi.mocked(rollbackJail);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const pendingRecord: PendingRecovery = {
jail_name: "sshd",
activated_at: "2024-01-01T12:00:00Z",
detected_at: "2024-01-01T12:00:30Z",
recovered: false,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderBanner() {
return render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter>
<RecoveryBanner />
</MemoryRouter>
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("RecoveryBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders nothing when pending recovery is null", async () => {
mockFetchPendingRecovery.mockResolvedValue(null);
renderBanner();
await waitFor(() => {
expect(mockFetchPendingRecovery).toHaveBeenCalled();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("renders warning when there is an unresolved pending recovery", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
renderBanner();
await waitFor(() => {
expect(screen.getByText(/fail2ban stopped responding after activating jail/i)).toBeInTheDocument();
});
expect(screen.getByText(/sshd/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /view logs/i })).toBeInTheDocument();
});
it("hides the banner when recovery is marked as recovered", async () => {
const recoveredRecord: PendingRecovery = { ...pendingRecord, recovered: true };
mockFetchPendingRecovery.mockResolvedValue(recoveredRecord);
renderBanner();
await waitFor(() => {
expect(mockFetchPendingRecovery).toHaveBeenCalled();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("calls rollbackJail and hides banner on successful rollback", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
mockRollbackJail.mockResolvedValue({
jail_name: "sshd",
disabled: true,
fail2ban_running: true,
active_jails: 0,
message: "Rolled back.",
});
renderBanner();
await waitFor(() => {
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
});
await userEvent.click(
screen.getByRole("button", { name: /disable & restart/i }),
);
expect(mockRollbackJail).toHaveBeenCalledWith("sshd");
});
it("shows rollback error when rollbackJail fails", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
mockRollbackJail.mockRejectedValue(new Error("Connection refused"));
renderBanner();
await waitFor(() => {
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
});
await userEvent.click(
screen.getByRole("button", { name: /disable & restart/i }),
);
await waitFor(() => {
expect(screen.getByText(/rollback failed/i)).toBeInTheDocument();
});
});
});

View File

@@ -5,12 +5,8 @@
* findtime, maxretry, port and logpath. Calls the activate endpoint on * findtime, maxretry, port and logpath. Calls the activate endpoint on
* confirmation and propagates the result via callbacks. * confirmation and propagates the result via callbacks.
* *
* Task 3 additions: * Runs pre-activation validation when the dialog opens and displays any
* - Runs pre-activation validation when the dialog opens and displays any * warnings or blocking errors before the user confirms.
* warnings or blocking errors before the user confirms.
* - Extended spinner text during the post-reload probe phase.
* - Calls `onCrashDetected` when the activation response signals that
* fail2ban stopped responding after the reload.
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -52,11 +48,6 @@ export interface ActivateJailDialogProps {
onClose: () => void; onClose: () => void;
/** Called after the jail has been successfully activated. */ /** Called after the jail has been successfully activated. */
onActivated: () => void; onActivated: () => void;
/**
* Called when fail2ban stopped responding after the jail was activated.
* The recovery banner will surface this to the user.
*/
onCrashDetected?: () => void;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -77,7 +68,6 @@ export function ActivateJailDialog({
open, open,
onClose, onClose,
onActivated, onActivated,
onCrashDetected,
}: ActivateJailDialogProps): React.JSX.Element { }: ActivateJailDialogProps): React.JSX.Element {
const [bantime, setBantime] = useState(""); const [bantime, setBantime] = useState("");
const [findtime, setFindtime] = useState(""); const [findtime, setFindtime] = useState("");
@@ -173,9 +163,6 @@ export function ActivateJailDialog({
setValidationWarnings(result.validation_warnings); setValidationWarnings(result.validation_warnings);
} }
resetForm(); resetForm();
if (!result.fail2ban_running) {
onCrashDetected?.();
}
onActivated(); onActivated();
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@@ -339,9 +326,10 @@ export function ActivateJailDialog({
style={{ marginTop: tokens.spacingVerticalS }} style={{ marginTop: tokens.spacingVerticalS }}
> >
<MessageBarBody> <MessageBarBody>
<MessageBarTitle>Activation Failed System Recovered</MessageBarTitle> <MessageBarTitle>Activation Failed Configuration Rolled Back</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed. The server The configuration for jail &ldquo;{jail.name}&rdquo; has been
has been automatically recovered. rolled back to its previous state and fail2ban is running
normally. Review the configuration and try activating again.
</MessageBarBody> </MessageBarBody>
</MessageBar> </MessageBar>
)} )}
@@ -351,10 +339,12 @@ export function ActivateJailDialog({
style={{ marginTop: tokens.spacingVerticalS }} style={{ marginTop: tokens.spacingVerticalS }}
> >
<MessageBarBody> <MessageBarBody>
<MessageBarTitle>Activation Failed Manual Intervention Required</MessageBarTitle> <MessageBarTitle>Activation Failed Rollback Unsuccessful</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed and Activation of jail &ldquo;{jail.name}&rdquo; failed and the
automatic recovery was unsuccessful. Manual intervention is automatic rollback did not complete. The file{" "}
required. <code>jail.d/{jail.name}.local</code> may still contain{" "}
<code>enabled = true</code>. Check the fail2ban logs, correct
the file manually, and restart fail2ban.
</MessageBarBody> </MessageBarBody>
</MessageBar> </MessageBar>
)} )}

View File

@@ -33,7 +33,6 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider"; import { useAuth } from "../providers/AuthProvider";
import { useServerStatus } from "../hooks/useServerStatus"; import { useServerStatus } from "../hooks/useServerStatus";
import { useBlocklistStatus } from "../hooks/useBlocklist"; import { useBlocklistStatus } from "../hooks/useBlocklist";
import { RecoveryBanner } from "../components/common/RecoveryBanner";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles // Styles
@@ -336,8 +335,6 @@ export function MainLayout(): React.JSX.Element {
</MessageBar> </MessageBar>
</div> </div>
)} )}
{/* Recovery banner — shown when fail2ban crashed after a jail activation */}
<RecoveryBanner />
{/* Blocklist import error warning — shown when the last scheduled import had errors */} {/* Blocklist import error warning — shown when the last scheduled import had errors */}
{blocklistHasErrors && ( {blocklistHasErrors && (
<div className={styles.warningBar} role="alert"> <div className={styles.warningBar} role="alert">