/** * ServerTab — fail2ban server-level settings editor. * * Provides form fields for live server settings (log level, log target, * DB purge age, DB max matches), action buttons (flush logs, reload fail2ban, * restart fail2ban), world map color threshold configuration, and service * health + log viewer. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Field, Input, MessageBar, MessageBarBody, Select, Skeleton, SkeletonItem, Text, tokens, } from "@fluentui/react-components"; import { DocumentArrowDown24Regular, ArrowSync24Regular, } from "@fluentui/react-icons"; import { ApiError } from "../../api/client"; import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config"; import { useServerSettings } from "../../hooks/useConfig"; import { useAutoSave } from "../../hooks/useAutoSave"; import { fetchMapColorThresholds, updateMapColorThresholds, reloadConfig, restartFail2Ban, } from "../../api/config"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { ServerHealthSection } from "./ServerHealthSection"; import { useConfigStyles } from "./configStyles"; /** Available fail2ban log levels in descending severity order. */ const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]; /** * Tab component for editing live fail2ban server settings. * * @returns JSX element. */ export function ServerTab(): React.JSX.Element { const styles = useConfigStyles(); const { settings, loading, error, updateSettings, flush } = useServerSettings(); const [logLevel, setLogLevel] = useState(""); const [logTarget, setLogTarget] = useState(""); const [dbPurgeAge, setDbPurgeAge] = useState(""); const [dbMaxMatches, setDbMaxMatches] = useState(""); const [flushing, setFlushing] = useState(false); 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 const [mapThresholds, setMapThresholds] = useState(null); const [mapThresholdHigh, setMapThresholdHigh] = useState(""); const [mapThresholdMedium, setMapThresholdMedium] = useState(""); const [mapThresholdLow, setMapThresholdLow] = useState(""); const [mapLoadError, setMapLoadError] = useState(null); const effectiveLogLevel = logLevel || settings?.log_level || ""; const effectiveLogTarget = logTarget || settings?.log_target || ""; const effectiveDbPurgeAge = dbPurgeAge || (settings ? String(settings.db_purge_age) : ""); const effectiveDbMaxMatches = dbMaxMatches || (settings ? String(settings.db_max_matches) : ""); const updatePayload = useMemo(() => { const update: ServerSettingsUpdate = {}; if (effectiveLogLevel) update.log_level = effectiveLogLevel; if (effectiveLogTarget) update.log_target = effectiveLogTarget; if (effectiveDbPurgeAge) update.db_purge_age = Number(effectiveDbPurgeAge); if (effectiveDbMaxMatches) update.db_max_matches = Number(effectiveDbMaxMatches); return update; }, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]); const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = useAutoSave(updatePayload, updateSettings); const handleFlush = useCallback(async () => { setFlushing(true); setMsg(null); try { const result = await flush(); setMsg({ text: `Logs flushed: ${result}`, ok: true }); } catch (err: unknown) { setMsg({ text: err instanceof ApiError ? err.message : "Flush failed.", ok: false, }); } finally { setFlushing(false); } }, [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. const loadMapThresholds = useCallback(async (): Promise => { try { const data = await fetchMapColorThresholds(); setMapThresholds(data); setMapThresholdHigh(String(data.threshold_high)); setMapThresholdMedium(String(data.threshold_medium)); setMapThresholdLow(String(data.threshold_low)); setMapLoadError(null); } catch (err) { setMapLoadError( err instanceof ApiError ? err.message : "Failed to load map color thresholds", ); } }, []); useEffect(() => { void loadMapThresholds(); }, [loadMapThresholds]); // Map threshold validation and auto-save. const mapHigh = Number(mapThresholdHigh); const mapMedium = Number(mapThresholdMedium); const mapLow = Number(mapThresholdLow); const mapValidationError = useMemo(() => { if (!mapThresholds) return null; if (isNaN(mapHigh) || isNaN(mapMedium) || isNaN(mapLow)) return "All thresholds must be valid numbers."; if (mapHigh <= 0 || mapMedium <= 0 || mapLow <= 0) return "All thresholds must be positive integers."; if (!(mapHigh > mapMedium && mapMedium > mapLow)) return "Thresholds must satisfy: high > medium > low."; return null; }, [mapHigh, mapMedium, mapLow, mapThresholds]); const [mapValidPayload, setMapValidPayload] = useState({ threshold_high: mapThresholds?.threshold_high ?? 0, threshold_medium: mapThresholds?.threshold_medium ?? 0, threshold_low: mapThresholds?.threshold_low ?? 0, }); useEffect(() => { if (mapValidationError !== null || !mapThresholds) return; setMapValidPayload({ threshold_high: mapHigh, threshold_medium: mapMedium, threshold_low: mapLow, }); }, [mapHigh, mapMedium, mapLow, mapValidationError, mapThresholds]); const saveMapThresholds = useCallback( async (payload: MapColorThresholdsUpdate): Promise => { await updateMapColorThresholds(payload); }, [], ); const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } = useAutoSave(mapValidPayload, saveMapThresholds); if (loading) { return (
); } if (error) return ( {error} ); return (
{/* Service Health & Log Viewer section — shown first so users can immediately see whether fail2ban is reachable before editing settings. */}
{ setLogTarget(d.value); }} />
{ setDbPurgeAge(d.value); }} /> { setDbMaxMatches(d.value); }} />
{msg && ( {msg.text} )}
{/* Map Color Thresholds section */} {mapLoadError ? (
{mapLoadError}
) : mapThresholds ? (
Map Color Thresholds Configure the ban count thresholds that determine country fill colors on the World Map. Countries with zero bans remain transparent. Colors smoothly interpolate between thresholds.
{mapValidationError && ( {mapValidationError} )}
{ setMapThresholdLow(d.value); }} min={1} /> { setMapThresholdMedium(d.value); }} min={1} /> { setMapThresholdHigh(d.value); }} min={1} />
• 1 to {mapThresholdLow}: Light green → Full green
• {mapThresholdLow} to {mapThresholdMedium}: Green → Yellow
• {mapThresholdMedium} to {mapThresholdHigh}: Yellow → Red
• {mapThresholdHigh}+: Solid red
) : null}
); }