From 9630aea877d4f42cca1c1c4180348dec6020239c Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 14 Mar 2026 21:55:30 +0100 Subject: [PATCH] Merge Map tab into Server tab and remove Map tab The Map tab provided a form for editing world-map color thresholds (low, medium, high). Moving this into the Server tab consolidates all server-side configuration in one place. - Add map color thresholds section to ServerTab with full validation - Load map thresholds on component mount with useEffect - Implement auto-save for threshold changes via useAutoSave hook - Display threshold color interpolation guide - Remove MapTab component import from ConfigPage - Remove 'map' from TabValue type - Remove Map tab element from TabList - Remove conditional render for MapTab - Remove MapTab from barrel export (index.ts) - Delete MapTab.tsx file - Update ConfigPage test to remove MapTab mock All 123 frontend tests pass. --- frontend/src/components/config/MapTab.tsx | 212 ------------------ frontend/src/components/config/ServerTab.tsx | 165 +++++++++++++- frontend/src/components/config/index.ts | 1 - frontend/src/pages/ConfigPage.tsx | 7 +- .../src/pages/__tests__/ConfigPage.test.tsx | 1 - 5 files changed, 163 insertions(+), 223 deletions(-) delete mode 100644 frontend/src/components/config/MapTab.tsx diff --git a/frontend/src/components/config/MapTab.tsx b/frontend/src/components/config/MapTab.tsx deleted file mode 100644 index f552e72..0000000 --- a/frontend/src/components/config/MapTab.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/** - * MapTab — world map color threshold configuration editor. - * - * Allows the user to set the low / medium / high ban-count thresholds - * that drive country fill colors on the World Map page. - */ - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - Field, - Input, - MessageBar, - MessageBarBody, - Skeleton, - SkeletonItem, - Text, - tokens, -} from "@fluentui/react-components"; -import { ApiError } from "../../api/client"; -import { - fetchMapColorThresholds, - updateMapColorThresholds, -} from "../../api/config"; -import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config"; -import { useAutoSave } from "../../hooks/useAutoSave"; -import { AutoSaveIndicator } from "./AutoSaveIndicator"; -import { useConfigStyles } from "./configStyles"; - -// --------------------------------------------------------------------------- -// Inner form — only mounted after data is loaded. -// --------------------------------------------------------------------------- - -interface MapFormProps { - initial: MapColorThresholdsResponse; -} - -function MapForm({ initial }: MapFormProps): React.JSX.Element { - const styles = useConfigStyles(); - const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high)); - const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium)); - const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low)); - - const high = Number(thresholdHigh); - const medium = Number(thresholdMedium); - const low = Number(thresholdLow); - - const validationError = useMemo(() => { - if (isNaN(high) || isNaN(medium) || isNaN(low)) - return "All thresholds must be valid numbers."; - if (high <= 0 || medium <= 0 || low <= 0) - return "All thresholds must be positive integers."; - if (!(high > medium && medium > low)) - return "Thresholds must satisfy: high > medium > low."; - return null; - }, [high, medium, low]); - - // Only pass a new payload to useAutoSave when all values are valid. - const [validPayload, setValidPayload] = useState({ - threshold_high: initial.threshold_high, - threshold_medium: initial.threshold_medium, - threshold_low: initial.threshold_low, - }); - - useEffect(() => { - if (validationError !== null) return; - setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low }); - }, [high, medium, low, validationError]); - - const saveThresholds = useCallback( - async (payload: MapColorThresholdsUpdate): Promise => { - await updateMapColorThresholds(payload); - }, - [], - ); - - const { status: saveStatus, errorText: saveErrorText, retry: retrySave } = - useAutoSave(validPayload, saveThresholds); - - return ( -
-
- - 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. - - -
- -
- - {validationError && ( - - {validationError} - - )} - -
- - { - setThresholdLow(d.value); - }} - min={1} - /> - - - { - setThresholdMedium(d.value); - }} - min={1} - /> - - - { - setThresholdHigh(d.value); - }} - min={1} - /> - -
- - - • 1 to {thresholdLow}: Light green → Full green -
• {thresholdLow} to {thresholdMedium}: Green → Yellow -
• {thresholdMedium} to {thresholdHigh}: Yellow → Red -
• {thresholdHigh}+: Solid red -
-
-
- ); -} - -// --------------------------------------------------------------------------- -// Outer loader component. -// --------------------------------------------------------------------------- - -/** - * Tab component for editing world-map ban-count color thresholds. - * - * @returns JSX element. - */ -export function MapTab(): React.JSX.Element { - const [thresholds, setThresholds] = useState(null); - const [loadError, setLoadError] = useState(null); - - const load = useCallback(async (): Promise => { - try { - const data = await fetchMapColorThresholds(); - setThresholds(data); - } catch (err) { - setLoadError( - err instanceof ApiError ? err.message : "Failed to load map color thresholds", - ); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - if (!thresholds && !loadError) { - return ( - -
- - - -
-
- ); - } - - if (loadError) - return ( - - {loadError} - - ); - - if (!thresholds) return <>; - - return ; -} - - diff --git a/frontend/src/components/config/ServerTab.tsx b/frontend/src/components/config/ServerTab.tsx index ac9819a..8b543c5 100644 --- a/frontend/src/components/config/ServerTab.tsx +++ b/frontend/src/components/config/ServerTab.tsx @@ -2,10 +2,11 @@ * ServerTab — fail2ban server-level settings editor. * * Provides form fields for live server settings (log level, log target, - * DB purge age, DB max matches) and a "Flush Logs" action button. + * DB purge age, DB max matches), a "Flush Logs" action button, and + * world map color threshold configuration. */ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Field, @@ -15,15 +16,20 @@ import { Select, Skeleton, SkeletonItem, + Text, tokens, } from "@fluentui/react-components"; import { DocumentArrowDown24Regular, } from "@fluentui/react-icons"; import { ApiError } from "../../api/client"; -import type { ServerSettingsUpdate } from "../../types/config"; +import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config"; import { useServerSettings } from "../../hooks/useConfig"; import { useAutoSave } from "../../hooks/useAutoSave"; +import { + fetchMapColorThresholds, + updateMapColorThresholds, +} from "../../api/config"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { useConfigStyles } from "./configStyles"; @@ -46,6 +52,13 @@ export function ServerTab(): React.JSX.Element { const [flushing, setFlushing] = useState(false); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); + // 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 = @@ -83,6 +96,67 @@ export function ServerTab(): React.JSX.Element { } }, [flush]); + // 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 ( @@ -195,6 +269,91 @@ export function ServerTab(): React.JSX.Element { )} + + {/* 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} ); } diff --git a/frontend/src/components/config/index.ts b/frontend/src/components/config/index.ts index df75def..1a0b7a1 100644 --- a/frontend/src/components/config/index.ts +++ b/frontend/src/components/config/index.ts @@ -34,7 +34,6 @@ export { JailFilesTab } from "./JailFilesTab"; export { JailFileForm } from "./JailFileForm"; export { JailsTab } from "./JailsTab"; export { LogTab } from "./LogTab"; -export { MapTab } from "./MapTab"; export { RawConfigSection } from "./RawConfigSection"; export type { RawConfigSectionProps } from "./RawConfigSection"; export { RegexList } from "./RegexList"; diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 77a841e..a11590b 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -8,8 +8,7 @@ * Jails — per-jail config accordion with inline editing * Filters — structured filter.d form editor * Actions — structured action.d form editor - * Server — server-level settings, logging, database config + flush logs - * Map — map color threshold configuration + * Server — server-level settings, logging, database config, map thresholds + flush logs * Regex Tester — live pattern tester * Export — raw file editors for jail, filter, and action files */ @@ -21,7 +20,6 @@ import { FiltersTab, JailsTab, LogTab, - MapTab, RegexTesterTab, ServerTab, } from "../components/config"; @@ -57,7 +55,6 @@ type TabValue = | "filters" | "actions" | "server" - | "map" | "regex" | "log"; @@ -87,7 +84,6 @@ export function ConfigPage(): React.JSX.Element { Filters Actions Server - Map Regex Tester Log @@ -97,7 +93,6 @@ export function ConfigPage(): React.JSX.Element { {tab === "filters" && } {tab === "actions" && } {tab === "server" && } - {tab === "map" && } {tab === "regex" && } {tab === "log" && } diff --git a/frontend/src/pages/__tests__/ConfigPage.test.tsx b/frontend/src/pages/__tests__/ConfigPage.test.tsx index 475eabe..0710f05 100644 --- a/frontend/src/pages/__tests__/ConfigPage.test.tsx +++ b/frontend/src/pages/__tests__/ConfigPage.test.tsx @@ -10,7 +10,6 @@ vi.mock("../../components/config", () => ({ FiltersTab: () =>
FiltersTab
, ActionsTab: () =>
ActionsTab
, ServerTab: () =>
ServerTab
, - MapTab: () =>
MapTab
, RegexTesterTab: () =>
RegexTesterTab
, ExportTab: () =>
ExportTab
, }));