Add Deactivate Jail button for inactive jails with local override
- Add has_local_override field to InactiveJail model
- Update _build_inactive_jail and list_inactive_jails to compute the field
- Add delete_jail_local_override() service function
- Add DELETE /api/config/jails/{name}/local router endpoint
- Surface has_local_override in frontend InactiveJail type
- Show Deactivate Jail button in JailsTab when has_local_override is true
- Add tests: TestBuildInactiveJail, TestListInactiveJails, TestDeleteJailLocalOverride
This commit is contained in:
@@ -39,10 +39,8 @@ import type {
|
||||
LogPreviewResponse,
|
||||
MapColorThresholdsResponse,
|
||||
MapColorThresholdsUpdate,
|
||||
PendingRecovery,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
RollbackResponse,
|
||||
ServerSettingsResponse,
|
||||
ServerSettingsUpdate,
|
||||
JailFileConfig,
|
||||
@@ -552,6 +550,18 @@ export async function deactivateJail(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||
*
|
||||
* Only valid when the jail is **not** currently active. Use this to clean up
|
||||
* leftover ``.local`` files after a jail has been fully deactivated.
|
||||
*
|
||||
* @param name - The jail name.
|
||||
*/
|
||||
export async function deleteJailLocalOverride(name: string): Promise<void> {
|
||||
await del<undefined>(ENDPOINTS.configJailLocalOverride(name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fail2ban log viewer (Task 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -593,21 +603,3 @@ export async function validateJailConfig(
|
||||
): Promise<JailValidationResult> {
|
||||
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the pending crash-recovery record, if any.
|
||||
*
|
||||
* Returns null when fail2ban is healthy and no recovery is pending.
|
||||
*/
|
||||
export async function fetchPendingRecovery(): Promise<PendingRecovery | null> {
|
||||
return get<PendingRecovery | null>(ENDPOINTS.configPendingRecovery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a bad jail — disables it and attempts to restart fail2ban.
|
||||
*
|
||||
* @param name - Name of the jail to disable.
|
||||
*/
|
||||
export async function rollbackJail(name: string): Promise<RollbackResponse> {
|
||||
return post<RollbackResponse>(ENDPOINTS.configJailRollback(name), undefined);
|
||||
}
|
||||
|
||||
@@ -71,11 +71,10 @@ export const ENDPOINTS = {
|
||||
`/config/jails/${encodeURIComponent(name)}/activate`,
|
||||
configJailDeactivate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
||||
configJailLocalOverride: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/local`,
|
||||
configJailValidate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/validate`,
|
||||
configJailRollback: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/rollback`,
|
||||
configPendingRecovery: "/config/pending-recovery" as string,
|
||||
configGlobal: "/config/global",
|
||||
configReload: "/config/reload",
|
||||
configRestart: "/config/restart",
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ApiError } from "../../api/client";
|
||||
import {
|
||||
addLogPath,
|
||||
deactivateJail,
|
||||
deleteJailLocalOverride,
|
||||
deleteLogPath,
|
||||
fetchInactiveJails,
|
||||
fetchJailConfigFileContent,
|
||||
@@ -573,7 +574,7 @@ function JailConfigDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readOnly && (onActivate !== undefined || onValidate !== undefined) && (
|
||||
{readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
|
||||
{onValidate !== undefined && (
|
||||
<Button
|
||||
@@ -585,6 +586,15 @@ function JailConfigDetail({
|
||||
{validating ? "Validating…" : "Validate Config"}
|
||||
</Button>
|
||||
)}
|
||||
{onDeactivate !== undefined && (
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<LockOpen24Regular />}
|
||||
onClick={onDeactivate}
|
||||
>
|
||||
Deactivate Jail
|
||||
</Button>
|
||||
)}
|
||||
{onActivate !== undefined && (
|
||||
<Button
|
||||
appearance="primary"
|
||||
@@ -618,8 +628,8 @@ function JailConfigDetail({
|
||||
interface InactiveJailDetailProps {
|
||||
jail: InactiveJail;
|
||||
onActivate: () => void;
|
||||
/** Whether to show and call onCrashDetected on activation crash. */
|
||||
onCrashDetected?: () => void;
|
||||
/** Called when the user requests removal of the .local override file. */
|
||||
onDeactivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -636,6 +646,7 @@ interface InactiveJailDetailProps {
|
||||
function InactiveJailDetail({
|
||||
jail,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
}: InactiveJailDetailProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [validating, setValidating] = useState(false);
|
||||
@@ -729,6 +740,7 @@ function InactiveJailDetail({
|
||||
onSave={async () => { /* read-only — never called */ }}
|
||||
readOnly
|
||||
onActivate={onActivate}
|
||||
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
|
||||
onValidate={handleValidate}
|
||||
validating={validating}
|
||||
/>
|
||||
@@ -746,12 +758,7 @@ function InactiveJailDetail({
|
||||
*
|
||||
* @returns 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 {
|
||||
export function JailsTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { jails, loading, error, refresh, updateJail } =
|
||||
useJailConfigs();
|
||||
@@ -786,6 +793,15 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
||||
.catch(() => { /* non-critical — list refreshes on next load */ });
|
||||
}, [refresh, loadInactive]);
|
||||
|
||||
const handleDeactivateInactive = useCallback((name: string): void => {
|
||||
deleteJailLocalOverride(name)
|
||||
.then(() => {
|
||||
setSelectedName(null);
|
||||
loadInactive();
|
||||
})
|
||||
.catch(() => { /* non-critical — list refreshes on next load */ });
|
||||
}, [loadInactive]);
|
||||
|
||||
const handleActivated = useCallback((): void => {
|
||||
setActivateTarget(null);
|
||||
setSelectedName(null);
|
||||
@@ -890,7 +906,11 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
||||
<InactiveJailDetail
|
||||
jail={selectedInactiveJail}
|
||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||
onCrashDetected={onCrashDetected}
|
||||
onDeactivate={
|
||||
selectedInactiveJail.has_local_override
|
||||
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</ConfigListDetail>
|
||||
@@ -901,7 +921,6 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
||||
open={activateTarget !== null}
|
||||
onClose={() => { setActivateTarget(null); }}
|
||||
onActivated={handleActivated}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
|
||||
<CreateJailDialog
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* - "Activate" button is enabled when validation passes.
|
||||
* - Dialog stays open and shows an error when the backend returns active=false.
|
||||
* - `onActivated` is called and dialog closes when backend returns active=true.
|
||||
* - `onCrashDetected` is called when fail2ban_running is false after activation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -55,6 +54,7 @@ const baseJail: InactiveJail = {
|
||||
bantime_escalation: null,
|
||||
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
|
||||
enabled: false,
|
||||
has_local_override: false,
|
||||
};
|
||||
|
||||
/** Successful activation response. */
|
||||
@@ -98,7 +98,6 @@ interface DialogProps {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
onActivated?: () => void;
|
||||
onCrashDetected?: () => void;
|
||||
}
|
||||
|
||||
function renderDialog({
|
||||
@@ -106,7 +105,6 @@ function renderDialog({
|
||||
open = true,
|
||||
onClose = vi.fn(),
|
||||
onActivated = vi.fn(),
|
||||
onCrashDetected = vi.fn(),
|
||||
}: DialogProps = {}) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
@@ -115,7 +113,6 @@ function renderDialog({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onActivated={onActivated}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
</FluentProvider>,
|
||||
);
|
||||
@@ -202,28 +199,4 @@ describe("ActivateJailDialog", () => {
|
||||
expect(onActivated).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onCrashDetected when fail2ban_running is false after activation", async () => {
|
||||
mockValidateJailConfig.mockResolvedValue(validationPassed);
|
||||
mockActivateJail.mockResolvedValue({
|
||||
...successResponse,
|
||||
fail2ban_running: false,
|
||||
});
|
||||
|
||||
const onActivated = vi.fn();
|
||||
const onCrashDetected = vi.fn();
|
||||
renderDialog({ onActivated, onCrashDetected });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
|
||||
await userEvent.click(activateBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCrashDetected).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(onActivated).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -524,6 +524,11 @@ export interface InactiveJail {
|
||||
source_file: string;
|
||||
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* True when a ``jail.d/{name}.local`` override file exists for this jail.
|
||||
* Indicates that a "Deactivate Jail" cleanup action is available.
|
||||
*/
|
||||
has_local_override: boolean;
|
||||
}
|
||||
|
||||
export interface InactiveJailListResponse {
|
||||
@@ -581,20 +586,6 @@ export interface JailValidationResult {
|
||||
issues: JailValidationIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded when fail2ban stops responding shortly after a jail activation.
|
||||
* Surfaced by `GET /api/config/pending-recovery`.
|
||||
*/
|
||||
export interface PendingRecovery {
|
||||
jail_name: string;
|
||||
/** ISO-8601 datetime string. */
|
||||
activated_at: string;
|
||||
/** ISO-8601 datetime string. */
|
||||
detected_at: string;
|
||||
/** True once fail2ban comes back online after the crash. */
|
||||
recovered: boolean;
|
||||
}
|
||||
|
||||
/** Response from `POST /api/config/jails/{name}/rollback`. */
|
||||
export interface RollbackResponse {
|
||||
jail_name: string;
|
||||
|
||||
Reference in New Issue
Block a user