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:
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
<Tab value="filters">Filters</Tab>
|
||||
<Tab value="actions">Actions</Tab>
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
<Tab value="regex">Regex Tester</Tab>
|
||||
<Tab value="log">Log</Tab>
|
||||
</TabList>
|
||||
@@ -97,7 +93,6 @@ export function ConfigPage(): React.JSX.Element {
|
||||
{tab === "filters" && <FiltersTab />}
|
||||
{tab === "actions" && <ActionsTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
{tab === "regex" && <RegexTesterTab />}
|
||||
{tab === "log" && <LogTab />}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ vi.mock("../../components/config", () => ({
|
||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</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>,
|
||||
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user