Add reload and restart buttons to Server tab

Adds ability to reload or restart fail2ban service from the Server tab UI.

Backend changes:
- Add new restart() method to jail_service.py that sends 'restart' command
- Add new POST /api/config/restart endpoint in config router
- Endpoint returns 204 on success, 502 if fail2ban unreachable
- Includes structured logging via 'fail2ban_restarted' log entry

Frontend changes:
- Add configRestart endpoint to endpoints.ts
- Add restartFail2Ban() API function in config.ts API module
- Import ArrowSync24Regular icon from Fluent UI
- Add reload and restart button handlers to ServerTab
- Display 'Reload fail2ban' and 'Restart fail2ban' buttons in action row
- Show loading spinner during operation
- Display success/error MessageBar with appropriate feedback
- Update ServerTab docstring to document new buttons

All 115 frontend tests pass.
This commit is contained in:
2026-03-14 22:03:58 +01:00
parent 1da38361a9
commit 1e33220f59
5 changed files with 122 additions and 3 deletions

View File

@@ -366,6 +366,40 @@ async def reload_fail2ban(
raise _bad_gateway(exc) from exc raise _bad_gateway(exc) from exc
# Restart endpoint
# ---------------------------------------------------------------------------
@router.post(
"/restart",
status_code=status.HTTP_204_NO_CONTENT,
summary="Restart the fail2ban service",
)
async def restart_fail2ban(
request: Request,
_auth: AuthDep,
) -> None:
"""Trigger a full fail2ban service restart.
The fail2ban daemon is completely stopped and then started again,
re-reading all configuration files in the process.
Args:
request: Incoming request.
_auth: Validated session.
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
# Perform restart by sending the restart command via the fail2ban socket.
# If fail2ban is not running, this will raise an exception, and we return 502.
await jail_service.restart(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Regex tester (stateless) # Regex tester (stateless)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -596,6 +596,29 @@ async def reload_all(
raise JailOperationError(str(exc)) from exc raise JailOperationError(str(exc)) from exc
async def restart(socket_path: str) -> None:
"""Restart the fail2ban service (daemon).
Sends the 'restart' command to the fail2ban daemon via the Unix socket.
All jails are stopped and the daemon is restarted, re-reading all
configuration from scratch.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Raises:
JailOperationError: If fail2ban reports the operation failed.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["restart"]))
log.info("fail2ban_restarted")
except ValueError as exc:
raise JailOperationError(str(exc)) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API — Ban / Unban # Public API — Ban / Unban
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -88,7 +88,7 @@ export async function updateGlobalConfig(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Reload // Reload and Restart
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function reloadConfig( export async function reloadConfig(
@@ -96,6 +96,11 @@ export async function reloadConfig(
await post<undefined>(ENDPOINTS.configReload, undefined); await post<undefined>(ENDPOINTS.configReload, undefined);
} }
export async function restartFail2Ban(
): Promise<void> {
await post<undefined>(ENDPOINTS.configRestart, undefined);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Regex tester // Regex tester
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -78,6 +78,7 @@ export const ENDPOINTS = {
configPendingRecovery: "/config/pending-recovery" as string, configPendingRecovery: "/config/pending-recovery" as string,
configGlobal: "/config/global", configGlobal: "/config/global",
configReload: "/config/reload", configReload: "/config/reload",
configRestart: "/config/restart",
configRegexTest: "/config/regex-test", configRegexTest: "/config/regex-test",
configPreviewLog: "/config/preview-log", configPreviewLog: "/config/preview-log",
configMapColorThresholds: "/config/map-color-thresholds", configMapColorThresholds: "/config/map-color-thresholds",

View File

@@ -2,8 +2,9 @@
* ServerTab — fail2ban server-level settings editor. * ServerTab — fail2ban server-level settings editor.
* *
* Provides form fields for live server settings (log level, log target, * Provides form fields for live server settings (log level, log target,
* DB purge age, DB max matches), a "Flush Logs" action button, * DB purge age, DB max matches), action buttons (flush logs, reload fail2ban,
* world map color threshold configuration, and service health + log viewer. * restart fail2ban), world map color threshold configuration, and service
* health + log viewer.
*/ */
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -21,6 +22,7 @@ import {
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { import {
DocumentArrowDown24Regular, DocumentArrowDown24Regular,
ArrowSync24Regular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config"; import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
@@ -29,6 +31,8 @@ import { useAutoSave } from "../../hooks/useAutoSave";
import { import {
fetchMapColorThresholds, fetchMapColorThresholds,
updateMapColorThresholds, updateMapColorThresholds,
reloadConfig,
restartFail2Ban,
} from "../../api/config"; } from "../../api/config";
import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ServerHealthSection } from "./ServerHealthSection"; import { ServerHealthSection } from "./ServerHealthSection";
@@ -53,6 +57,10 @@ export function ServerTab(): React.JSX.Element {
const [flushing, setFlushing] = useState(false); const [flushing, setFlushing] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
// Reload/Restart state
const [isReloading, setIsReloading] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
// Map color thresholds // Map color thresholds
const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null); const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null);
const [mapThresholdHigh, setMapThresholdHigh] = useState(""); const [mapThresholdHigh, setMapThresholdHigh] = useState("");
@@ -97,6 +105,38 @@ export function ServerTab(): React.JSX.Element {
} }
}, [flush]); }, [flush]);
const handleReload = useCallback(async () => {
setIsReloading(true);
setMsg(null);
try {
await reloadConfig();
setMsg({ text: "fail2ban reloaded successfully", ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Reload failed.",
ok: false,
});
} finally {
setIsReloading(false);
}
}, []);
const handleRestart = useCallback(async () => {
setIsRestarting(true);
setMsg(null);
try {
await restartFail2Ban();
setMsg({ text: "fail2ban restart initiated", ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Restart failed.",
ok: false,
});
} finally {
setIsRestarting(false);
}
}, []);
// Load map color thresholds on mount. // Load map color thresholds on mount.
const loadMapThresholds = useCallback(async (): Promise<void> => { const loadMapThresholds = useCallback(async (): Promise<void> => {
try { try {
@@ -263,6 +303,22 @@ export function ServerTab(): React.JSX.Element {
> >
{flushing ? "Flushing…" : "Flush Logs"} {flushing ? "Flushing…" : "Flush Logs"}
</Button> </Button>
<Button
appearance="secondary"
icon={<ArrowSync24Regular />}
disabled={isReloading}
onClick={() => void handleReload()}
>
{isReloading ? "Reloading…" : "Reload fail2ban"}
</Button>
<Button
appearance="secondary"
icon={<ArrowSync24Regular />}
disabled={isRestarting}
onClick={() => void handleRestart()}
>
{isRestarting ? "Restarting…" : "Restart fail2ban"}
</Button>
</div> </div>
{msg && ( {msg && (
<MessageBar intent={msg.ok ? "success" : "error"}> <MessageBar intent={msg.ok ? "success" : "error"}>