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.
360 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|