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:
@@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 “{jail.name}” failed. The server
|
The configuration for jail “{jail.name}” 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 “{jail.name}” failed and
|
Activation of jail “{jail.name}” 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user