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.
This commit is contained in:
2026-03-14 21:55:30 +01:00
parent 037c18eb00
commit 9630aea877
5 changed files with 163 additions and 223 deletions

View File

@@ -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<string | null>(() => {
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<MapColorThresholdsUpdate>({
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<void> => {
await updateMapColorThresholds(payload);
},
[],
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(validPayload, saveThresholds);
return (
<div>
<div className={styles.sectionCard}>
<Text as="h3" size={500} weight="semibold" block>
Map Color Thresholds
</Text>
<Text
as="p"
size={300}
className={styles.infoText}
block
style={{ marginBottom: tokens.spacingVerticalM }}
>
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.
</Text>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={validationError ? "idle" : saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
{validationError && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBarBody>{validationError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.fieldRowThree}>
<Field label="Low Threshold (Green)" required>
<Input
type="number"
value={thresholdLow}
onChange={(_, d) => {
setThresholdLow(d.value);
}}
min={1}
/>
</Field>
<Field label="Medium Threshold (Yellow)" required>
<Input
type="number"
value={thresholdMedium}
onChange={(_, d) => {
setThresholdMedium(d.value);
}}
min={1}
/>
</Field>
<Field label="High Threshold (Red)" required>
<Input
type="number"
value={thresholdHigh}
onChange={(_, d) => {
setThresholdHigh(d.value);
}}
min={1}
/>
</Field>
</div>
<Text
as="p"
size={200}
className={styles.infoText}
style={{ marginTop: tokens.spacingVerticalS }}
>
1 to {thresholdLow}: Light green Full green
<br /> {thresholdLow} to {thresholdMedium}: Green Yellow
<br /> {thresholdMedium} to {thresholdHigh}: Yellow Red
<br /> {thresholdHigh}+: Solid red
</Text>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<MapColorThresholdsResponse | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
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 (
<Skeleton aria-label="Loading map settings…">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
</Skeleton>
);
}
if (loadError)
return (
<MessageBar intent="error">
<MessageBarBody>{loadError}</MessageBarBody>
</MessageBar>
);
if (!thresholds) return <></>;
return <MapForm initial={thresholds} />;
}

View File

@@ -2,10 +2,11 @@
* 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) 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 { import {
Button, Button,
Field, Field,
@@ -15,15 +16,20 @@ import {
Select, Select,
Skeleton, Skeleton,
SkeletonItem, SkeletonItem,
Text,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { import {
DocumentArrowDown24Regular, DocumentArrowDown24Regular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { ApiError } from "../../api/client"; 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 { useServerSettings } from "../../hooks/useConfig";
import { useAutoSave } from "../../hooks/useAutoSave"; import { useAutoSave } from "../../hooks/useAutoSave";
import {
fetchMapColorThresholds,
updateMapColorThresholds,
} from "../../api/config";
import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
@@ -46,6 +52,13 @@ 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);
// Map color thresholds
const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null);
const [mapThresholdHigh, setMapThresholdHigh] = useState("");
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
const [mapThresholdLow, setMapThresholdLow] = useState("");
const [mapLoadError, setMapLoadError] = useState<string | null>(null);
const effectiveLogLevel = logLevel || settings?.log_level || ""; const effectiveLogLevel = logLevel || settings?.log_level || "";
const effectiveLogTarget = logTarget || settings?.log_target || ""; const effectiveLogTarget = logTarget || settings?.log_target || "";
const effectiveDbPurgeAge = const effectiveDbPurgeAge =
@@ -83,6 +96,67 @@ export function ServerTab(): React.JSX.Element {
} }
}, [flush]); }, [flush]);
// Load map color thresholds on mount.
const loadMapThresholds = useCallback(async (): Promise<void> => {
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<string | null>(() => {
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<MapColorThresholdsUpdate>({
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<void> => {
await updateMapColorThresholds(payload);
},
[],
);
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
useAutoSave(mapValidPayload, saveMapThresholds);
if (loading) { if (loading) {
return ( return (
<Skeleton aria-label="Loading server settings…"> <Skeleton aria-label="Loading server settings…">
@@ -195,6 +269,91 @@ export function ServerTab(): React.JSX.Element {
</MessageBar> </MessageBar>
)} )}
</div> </div>
{/* Map Color Thresholds section */}
{mapLoadError ? (
<div className={styles.sectionCard}>
<MessageBar intent="error">
<MessageBarBody>{mapLoadError}</MessageBarBody>
</MessageBar>
</div>
) : mapThresholds ? (
<div className={styles.sectionCard}>
<Text as="h3" size={500} weight="semibold" block>
Map Color Thresholds
</Text>
<Text
as="p"
size={300}
className={styles.infoText}
block
style={{ marginBottom: tokens.spacingVerticalM }}
>
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.
</Text>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={mapValidationError ? "idle" : mapSaveStatus}
errorText={mapSaveErrorText}
onRetry={retryMapSave}
/>
</div>
{mapValidationError && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBarBody>{mapValidationError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.fieldRowThree}>
<Field label="Low Threshold (Green)" required>
<Input
type="number"
value={mapThresholdLow}
onChange={(_, d) => {
setMapThresholdLow(d.value);
}}
min={1}
/>
</Field>
<Field label="Medium Threshold (Yellow)" required>
<Input
type="number"
value={mapThresholdMedium}
onChange={(_, d) => {
setMapThresholdMedium(d.value);
}}
min={1}
/>
</Field>
<Field label="High Threshold (Red)" required>
<Input
type="number"
value={mapThresholdHigh}
onChange={(_, d) => {
setMapThresholdHigh(d.value);
}}
min={1}
/>
</Field>
</div>
<Text
as="p"
size={200}
className={styles.infoText}
style={{ marginTop: tokens.spacingVerticalS }}
>
1 to {mapThresholdLow}: Light green Full green
<br /> {mapThresholdLow} to {mapThresholdMedium}: Green Yellow
<br /> {mapThresholdMedium} to {mapThresholdHigh}: Yellow Red
<br /> {mapThresholdHigh}+: Solid red
</Text>
</div>
) : null}
</div> </div>
); );
} }

View File

@@ -34,7 +34,6 @@ export { JailFilesTab } from "./JailFilesTab";
export { JailFileForm } from "./JailFileForm"; export { JailFileForm } from "./JailFileForm";
export { JailsTab } from "./JailsTab"; export { JailsTab } from "./JailsTab";
export { LogTab } from "./LogTab"; export { LogTab } from "./LogTab";
export { MapTab } from "./MapTab";
export { RawConfigSection } from "./RawConfigSection"; export { RawConfigSection } from "./RawConfigSection";
export type { RawConfigSectionProps } from "./RawConfigSection"; export type { RawConfigSectionProps } from "./RawConfigSection";
export { RegexList } from "./RegexList"; export { RegexList } from "./RegexList";

View File

@@ -8,8 +8,7 @@
* Jails — per-jail config accordion with inline editing * Jails — per-jail config accordion with inline editing
* Filters — structured filter.d form editor * Filters — structured filter.d form editor
* Actions — structured action.d form editor * Actions — structured action.d form editor
* Server — server-level settings, logging, database config + flush logs * Server — server-level settings, logging, database config, map thresholds + flush logs
* Map — map color threshold configuration
* Regex Tester — live pattern tester * Regex Tester — live pattern tester
* Export — raw file editors for jail, filter, and action files * Export — raw file editors for jail, filter, and action files
*/ */
@@ -21,7 +20,6 @@ import {
FiltersTab, FiltersTab,
JailsTab, JailsTab,
LogTab, LogTab,
MapTab,
RegexTesterTab, RegexTesterTab,
ServerTab, ServerTab,
} from "../components/config"; } from "../components/config";
@@ -57,7 +55,6 @@ type TabValue =
| "filters" | "filters"
| "actions" | "actions"
| "server" | "server"
| "map"
| "regex" | "regex"
| "log"; | "log";
@@ -87,7 +84,6 @@ export function ConfigPage(): React.JSX.Element {
<Tab value="filters">Filters</Tab> <Tab value="filters">Filters</Tab>
<Tab value="actions">Actions</Tab> <Tab value="actions">Actions</Tab>
<Tab value="server">Server</Tab> <Tab value="server">Server</Tab>
<Tab value="map">Map</Tab>
<Tab value="regex">Regex Tester</Tab> <Tab value="regex">Regex Tester</Tab>
<Tab value="log">Log</Tab> <Tab value="log">Log</Tab>
</TabList> </TabList>
@@ -97,7 +93,6 @@ export function ConfigPage(): React.JSX.Element {
{tab === "filters" && <FiltersTab />} {tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />} {tab === "actions" && <ActionsTab />}
{tab === "server" && <ServerTab />} {tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />}
{tab === "regex" && <RegexTesterTab />} {tab === "regex" && <RegexTesterTab />}
{tab === "log" && <LogTab />} {tab === "log" && <LogTab />}
</div> </div>

View File

@@ -10,7 +10,6 @@ vi.mock("../../components/config", () => ({
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>, FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>, ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
ServerTab: () => <div data-testid="server-tab">ServerTab</div>, ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
MapTab: () => <div data-testid="map-tab">MapTab</div>,
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>, RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
ExportTab: () => <div data-testid="export-tab">ExportTab</div>, ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
})); }));