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:
@@ -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<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 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<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) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading server settings…">
|
||||
@@ -195,6 +269,91 @@ export function ServerTab(): React.JSX.Element {
|
||||
</MessageBar>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user