Files
BanGUI/frontend/src/components/config/ServerTab.tsx
Lukas 9630aea877 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.
2026-03-14 21:55:30 +01:00

360 lines
12 KiB
TypeScript

/**
* ServerTab — fail2ban server-level settings editor.
*
* Provides form fields for live server settings (log level, log target,
* DB purge age, DB max matches), a "Flush Logs" action button, and
* world map color threshold configuration.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Skeleton,
SkeletonItem,
Text,
tokens,
} from "@fluentui/react-components";
import {
DocumentArrowDown24Regular,
} 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,
} from "../../api/config";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
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);
// 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 =
dbPurgeAge || (settings ? String(settings.db_purge_age) : "");
const effectiveDbMaxMatches =
dbMaxMatches || (settings ? String(settings.db_max_matches) : "");
const updatePayload = useMemo<ServerSettingsUpdate>(() => {
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]);
// 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…">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
<SkeletonItem size={32} style={{ marginBottom: 8 }} />
<SkeletonItem size={32} />
</Skeleton>
);
}
if (error)
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
return (
<div>
<div className={styles.sectionCard}>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
<div className={styles.fieldRow}>
<Field label="Log Level">
<Select
value={effectiveLogLevel}
onChange={(_e, d) => {
setLogLevel(d.value);
}}
>
{LOG_LEVELS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
</Field>
<Field label="Log Target">
<Input
value={effectiveLogTarget}
placeholder="STDOUT / /var/log/fail2ban.log"
onChange={(_e, d) => {
setLogTarget(d.value);
}}
/>
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="DB Path">
<Input
readOnly
value={settings?.db_path ?? ""}
className={styles.codeFont}
/>
</Field>
<Field label="Syslog Socket">
<Input
readOnly
value={settings?.syslog_socket ?? "(not configured)"}
className={styles.codeFont}
/>
</Field>
</div>
<div className={styles.fieldRow}>
<Field
label="DB Purge Age (s)"
hint="Ban records older than this are removed from the fail2ban database."
>
<Input
type="number"
value={effectiveDbPurgeAge}
onChange={(_e, d) => {
setDbPurgeAge(d.value);
}}
/>
</Field>
<Field
label="DB Max Matches"
hint="Maximum number of log-line matches stored per ban record."
>
<Input
type="number"
value={effectiveDbMaxMatches}
onChange={(_e, d) => {
setDbMaxMatches(d.value);
}}
/>
</Field>
</div>
<div className={styles.buttonRow}>
<Button
appearance="secondary"
icon={<DocumentArrowDown24Regular />}
disabled={flushing}
onClick={() => void handleFlush()}
>
{flushing ? "Flushing…" : "Flush Logs"}
</Button>
</div>
{msg && (
<MessageBar intent={msg.ok ? "success" : "error"}>
<MessageBarBody>{msg.text}</MessageBarBody>
</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>
);
}