diff --git a/Docs/Tasks.md b/Docs/Tasks.md index ef06afe..0e63295 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -452,7 +452,11 @@ const source = timeRange === "24h" ? "fail2ban" : "archive"; --- -### TASK-023 — Replace loose `string` types with union types in config models +### TASK-023 — Replace loose `string` types with union types in config models (done) + +**Where fixed:** `frontend/src/types/config.ts`, `frontend/src/types/jail.ts`, `frontend/src/components/config/JailsTab.tsx`, `frontend/src/components/config/JailSectionPanel.tsx`, `backend/app/models/config.py` + +**Summary:** Added explicit union types for `DNSMode`, `LogEncoding`, and `BackendType` in frontend config models and backend Pydantic models. Updated the affected jail config form state and select handlers to use the narrowed types. **Where found:** `frontend/src/types/config.ts` — `JailConfig.use_dns`, `JailConfig.log_encoding`, `JailConfig.backend`, and several action/filter fields are typed as plain `string` despite having a fixed set of valid values defined by fail2ban. @@ -474,7 +478,11 @@ Update `JailConfig` (and the corresponding `JailConfigUpdate` patch type) to use --- -### TASK-024 — Add `useReducer` to `ServerTab` and `ConfFilesTab` +### TASK-024 — Add `useReducer` to `ServerTab` and `ConfFilesTab` (done) + +**Status:** done + +**Summary:** Replaced the local `useState` clusters in `ServerTab.tsx` and `ConfFilesTab.tsx` with reducer-based state management, ensuring compound updates such as flush/reload/restart transitions and file content edits update atomically. **Where found:** - `frontend/src/components/config/ServerTab.tsx` — 11 `useState` calls managing logLevel, logTarget, dbPurgeAge, dbMaxMatches, flushing, msg, isReloading, isRestarting, and three map threshold fields. diff --git a/frontend/src/components/config/ConfFilesTab.tsx b/frontend/src/components/config/ConfFilesTab.tsx index 269eeac..9f126a9 100644 --- a/frontend/src/components/config/ConfFilesTab.tsx +++ b/frontend/src/components/config/ConfFilesTab.tsx @@ -6,7 +6,7 @@ * be reused for both filter and action files. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; import { Accordion, AccordionHeader, @@ -27,6 +27,88 @@ import { ApiError } from "../../api/client"; import type { ConfFileEntry } from "../../types/config"; import { useConfigStyles } from "./configStyles"; +interface ConfFilesTabState { + files: ConfFileEntry[]; + loading: boolean; + error: string | null; + openItems: string[]; + contents: Record; + editedContents: Record; + saving: string | null; + msg: { text: string; ok: boolean } | null; + newName: string; + newContent: string; + creating: boolean; +} + +type ConfFilesTabAction = + | { type: "setFiles"; value: ConfFileEntry[] } + | { type: "setLoading"; value: boolean } + | { type: "setError"; value: string | null } + | { type: "setOpenItems"; value: string[] } + | { type: "setContent"; name: string; content: string } + | { type: "setEditedContent"; name: string; content: string } + | { type: "setSaving"; value: string | null } + | { type: "setMsg"; value: { text: string; ok: boolean } | null } + | { type: "setNewName"; value: string } + | { type: "setNewContent"; value: string } + | { type: "setCreating"; value: boolean }; + +export const initialConfFilesTabState: ConfFilesTabState = { + files: [], + loading: true, + error: null, + openItems: [], + contents: {}, + editedContents: {}, + saving: null, + msg: null, + newName: "", + newContent: "", + creating: false, +}; + +export function confFilesTabReducer( + state: ConfFilesTabState, + action: ConfFilesTabAction, +): ConfFilesTabState { + switch (action.type) { + case "setFiles": + return { ...state, files: action.value }; + case "setLoading": + return { ...state, loading: action.value }; + case "setError": + return { ...state, error: action.value }; + case "setOpenItems": + return { ...state, openItems: action.value }; + case "setContent": + return { + ...state, + contents: { ...state.contents, [action.name]: action.content }, + }; + case "setEditedContent": + return { + ...state, + editedContents: { + ...state.editedContents, + [action.name]: action.content, + }, + }; + case "setSaving": + return { ...state, saving: action.value }; + case "setMsg": + return { ...state, msg: action.value }; + case "setNewName": + return { ...state, newName: action.value }; + case "setNewContent": + return { ...state, newContent: action.value }; + case "setCreating": + return { ...state, creating: action.value }; + default: + return state; + } +} + export interface ConfFilesTabProps { /** Human-readable label, e.g. "Filter" or "Action". */ label: string; @@ -61,37 +143,43 @@ export function ConfFilesTab({ createFile, }: ConfFilesTabProps): React.JSX.Element { const styles = useConfigStyles(); - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [openItems, setOpenItems] = useState([]); - const [contents, setContents] = useState>({}); - const [editedContents, setEditedContents] = useState>( - {}, + const [state, dispatch] = useReducer( + confFilesTabReducer, + initialConfFilesTabState, ); - const [saving, setSaving] = useState(null); - const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); - const [newName, setNewName] = useState(""); - const [newContent, setNewContent] = useState(""); - const [creating, setCreating] = useState(false); + const { + files, + loading, + error, + openItems, + contents, + editedContents, + saving, + msg, + newName, + newContent, + creating, + } = state; const loadFiles = useCallback(async (signal?: AbortSignal) => { - setLoading(true); - setError(null); + dispatch({ type: "setLoading", value: true }); + dispatch({ type: "setError", value: null }); try { const resp = await fetchList(); if (signal?.aborted) return; - setFiles(resp.files); + dispatch({ type: "setFiles", value: resp.files }); } catch (err: unknown) { if (signal?.aborted) return; - setError( - err instanceof ApiError - ? err.message - : `Failed to load ${label.toLowerCase()} files.`, - ); + dispatch({ + type: "setError", + value: + err instanceof ApiError + ? err.message + : `Failed to load ${label.toLowerCase()} files.`, + }); } finally { if (!signal?.aborted) { - setLoading(false); + dispatch({ type: "setLoading", value: false }); } } }, [fetchList, label]); @@ -111,23 +199,33 @@ export function ConfFilesTab({ ) => { const next = data.openItems as string[]; const newlyOpened = next.filter((v) => !openItems.includes(v)); - setOpenItems(next); + dispatch({ type: "setOpenItems", value: next }); for (const name of newlyOpened) { if (!Object.prototype.hasOwnProperty.call(contents, name)) { void fetchFile(name) .then((c) => { - setContents((prev) => ({ ...prev, [name]: c.content })); - setEditedContents((prev) => ({ ...prev, [name]: c.content })); + dispatch({ + type: "setContent", + name, + content: c.content, + }); + dispatch({ + type: "setEditedContent", + name, + content: c.content, + }); }) .catch(() => { - setContents((prev) => ({ - ...prev, - [name]: "(failed to load)", - })); - setEditedContents((prev) => ({ - ...prev, - [name]: "(failed to load)", - })); + dispatch({ + type: "setContent", + name, + content: "(failed to load)", + }); + dispatch({ + type: "setEditedContent", + name, + content: "(failed to load)", + }); }); } } @@ -137,20 +235,23 @@ export function ConfFilesTab({ const handleSave = useCallback( async (name: string) => { - setSaving(name); - setMsg(null); + dispatch({ type: "setSaving", value: name }); + dispatch({ type: "setMsg", value: null }); try { const content = editedContents[name] ?? contents[name] ?? ""; await updateFile(name, { content }); - setContents((prev) => ({ ...prev, [name]: content })); - setMsg({ text: `${name} saved.`, ok: true }); + dispatch({ type: "setContent", name, content }); + dispatch({ type: "setMsg", value: { text: `${name} saved.`, ok: true } }); } catch (err: unknown) { - setMsg({ - text: err instanceof ApiError ? err.message : "Save failed.", - ok: false, + dispatch({ + type: "setMsg", + value: { + text: err instanceof ApiError ? err.message : "Save failed.", + ok: false, + }, }); } finally { - setSaving(null); + dispatch({ type: "setSaving", value: null }); } }, [editedContents, contents, updateFile], @@ -159,31 +260,45 @@ export function ConfFilesTab({ const handleCreate = useCallback(async () => { const name = newName.trim(); if (!name) return; - setCreating(true); - setMsg(null); + dispatch({ type: "setCreating", value: true }); + dispatch({ type: "setMsg", value: null }); try { const created = await createFile({ name, content: newContent }); - setFiles((prev) => [ - ...prev, - { name: created.name, filename: created.filename }, - ]); - setContents((prev) => ({ ...prev, [created.name]: created.content })); - setEditedContents((prev) => ({ - ...prev, - [created.name]: created.content, - })); - setNewName(""); - setNewContent(""); - setMsg({ text: `${created.filename} created.`, ok: true }); + dispatch({ + type: "setFiles", + value: [ + ...files, + { name: created.name, filename: created.filename }, + ], + }); + dispatch({ + type: "setContent", + name: created.name, + content: created.content, + }); + dispatch({ + type: "setEditedContent", + name: created.name, + content: created.content, + }); + dispatch({ type: "setNewName", value: "" }); + dispatch({ type: "setNewContent", value: "" }); + dispatch({ + type: "setMsg", + value: { text: `${created.filename} created.`, ok: true }, + }); } catch (err: unknown) { - setMsg({ - text: err instanceof ApiError ? err.message : "Create failed.", - ok: false, + dispatch({ + type: "setMsg", + value: { + text: err instanceof ApiError ? err.message : "Create failed.", + ok: false, + }, }); } finally { - setCreating(false); + dispatch({ type: "setCreating", value: false }); } - }, [newName, newContent, createFile]); + }, [createFile, files, newContent, newName]); if (loading) return ; if (error) @@ -247,10 +362,11 @@ export function ConfFilesTab({ fontFamily: "monospace", }} onChange={(_e, d) => { - setEditedContents((prev) => ({ - ...prev, - [file.name]: d.value, - })); + dispatch({ + type: "setEditedContent", + name: file.name, + content: d.value, + }); }} />
@@ -293,7 +409,7 @@ export function ConfFilesTab({ placeholder={`e.g. my-${label.toLowerCase()}`} className={styles.codeFont} onChange={(_e, d) => { - setNewName(d.value); + dispatch({ type: "setNewName", value: d.value }); }} /> @@ -308,7 +424,7 @@ export function ConfFilesTab({ fontFamily: "monospace", }} onChange={(_e, d) => { - setNewContent(d.value); + dispatch({ type: "setNewContent", value: d.value }); }} /> diff --git a/frontend/src/components/config/ServerTab.tsx b/frontend/src/components/config/ServerTab.tsx index c519fef..6c144c7 100644 --- a/frontend/src/components/config/ServerTab.tsx +++ b/frontend/src/components/config/ServerTab.tsx @@ -7,7 +7,7 @@ * health + log viewer. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer } from "react"; import { Button, Field, @@ -36,6 +36,97 @@ import { useConfigStyles } from "./configStyles"; /** Available fail2ban log levels in descending severity order. */ const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]; +interface ServerTabMessage { + text: string; + ok: boolean; +} + +interface ServerTabState { + logLevel: string; + logTarget: string; + dbPurgeAge: string; + dbMaxMatches: string; + flushing: boolean; + msg: ServerTabMessage | null; + isReloading: boolean; + isRestarting: boolean; + mapThresholdHigh: string; + mapThresholdMedium: string; + mapThresholdLow: string; + mapValidPayload: MapColorThresholdsUpdate; +} + +type ServerTabAction = + | { type: "setLogLevel"; value: string } + | { type: "setLogTarget"; value: string } + | { type: "setDbPurgeAge"; value: string } + | { type: "setDbMaxMatches"; value: string } + | { type: "setFlushing"; value: boolean } + | { type: "setMsg"; value: ServerTabMessage | null } + | { type: "setIsReloading"; value: boolean } + | { type: "setIsRestarting"; value: boolean } + | { + type: "setMapThresholds"; + high: string; + medium: string; + low: string; + } + | { type: "setMapValidPayload"; payload: MapColorThresholdsUpdate }; + +export const initialServerTabState: ServerTabState = { + logLevel: "", + logTarget: "", + dbPurgeAge: "", + dbMaxMatches: "", + flushing: false, + msg: null, + isReloading: false, + isRestarting: false, + mapThresholdHigh: "", + mapThresholdMedium: "", + mapThresholdLow: "", + mapValidPayload: { + threshold_high: 0, + threshold_medium: 0, + threshold_low: 0, + }, +}; + +export function serverTabReducer( + state: ServerTabState, + action: ServerTabAction, +): ServerTabState { + switch (action.type) { + case "setLogLevel": + return { ...state, logLevel: action.value }; + case "setLogTarget": + return { ...state, logTarget: action.value }; + case "setDbPurgeAge": + return { ...state, dbPurgeAge: action.value }; + case "setDbMaxMatches": + return { ...state, dbMaxMatches: action.value }; + case "setFlushing": + return { ...state, flushing: action.value }; + case "setMsg": + return { ...state, msg: action.value }; + case "setIsReloading": + return { ...state, isReloading: action.value }; + case "setIsRestarting": + return { ...state, isRestarting: action.value }; + case "setMapThresholds": + return { + ...state, + mapThresholdHigh: action.high, + mapThresholdMedium: action.medium, + mapThresholdLow: action.low, + }; + case "setMapValidPayload": + return { ...state, mapValidPayload: action.payload }; + default: + return state; + } +} + /** * Tab component for editing live fail2ban server settings. * @@ -45,27 +136,29 @@ export function ServerTab(): React.JSX.Element { const styles = useConfigStyles(); const { settings, loading, error, updateSettings, flush, reload, restart } = 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); + const [state, dispatch] = useReducer(serverTabReducer, initialServerTabState); - // Reload/Restart state - const [isReloading, setIsReloading] = useState(false); - const [isRestarting, setIsRestarting] = useState(false); + const { + logLevel, + logTarget, + dbPurgeAge, + dbMaxMatches, + flushing, + msg, + isReloading, + isRestarting, + mapThresholdHigh, + mapThresholdMedium, + mapThresholdLow, + mapValidPayload, + } = state; - // Map color thresholds const { thresholds: mapThresholds, error: mapThresholdsError, refresh: refreshMapThresholds, updateThresholds: updateMapThresholds, } = useMapColorThresholds(); - const [mapThresholdHigh, setMapThresholdHigh] = useState(""); - const [mapThresholdMedium, setMapThresholdMedium] = useState(""); - const [mapThresholdLow, setMapThresholdLow] = useState(""); const effectiveLogLevel = logLevel || settings?.log_level || ""; const effectiveLogTarget = logTarget || settings?.log_target || ""; @@ -78,8 +171,7 @@ export function ServerTab(): React.JSX.Element { const update: ServerSettingsUpdate = {}; if (effectiveLogLevel) update.log_level = effectiveLogLevel; if (effectiveLogTarget) update.log_target = effectiveLogTarget; - if (effectiveDbPurgeAge) - update.db_purge_age = Number(effectiveDbPurgeAge); + if (effectiveDbPurgeAge) update.db_purge_age = Number(effectiveDbPurgeAge); if (effectiveDbMaxMatches) update.db_max_matches = Number(effectiveDbMaxMatches); return update; @@ -89,62 +181,79 @@ export function ServerTab(): React.JSX.Element { useAutoSave(updatePayload, updateSettings); const handleFlush = useCallback(async () => { - setFlushing(true); - setMsg(null); + dispatch({ type: "setFlushing", value: true }); + dispatch({ type: "setMsg", value: null }); try { const result = await flush(); - setMsg({ text: `Logs flushed: ${result}`, ok: true }); + dispatch({ type: "setMsg", value: { text: `Logs flushed: ${result}`, ok: true } }); } catch (err: unknown) { - setMsg({ - text: err instanceof ApiError ? err.message : "Flush failed.", - ok: false, + dispatch({ + type: "setMsg", + value: { + text: err instanceof ApiError ? err.message : "Flush failed.", + ok: false, + }, }); } finally { - setFlushing(false); + dispatch({ type: "setFlushing", value: false }); } }, [flush]); - const handleReload = async (): Promise => { - setIsReloading(true); - setMsg(null); + const handleReload = useCallback(async (): Promise => { + dispatch({ type: "setIsReloading", value: true }); + dispatch({ type: "setMsg", value: null }); try { await reload(); - setMsg({ text: "fail2ban reloaded successfully", ok: true }); + dispatch({ + type: "setMsg", + value: { text: "fail2ban reloaded successfully", ok: true }, + }); } catch (err: unknown) { - setMsg({ - text: err instanceof ApiError ? err.message : "Reload failed.", - ok: false, + dispatch({ + type: "setMsg", + value: { + text: err instanceof ApiError ? err.message : "Reload failed.", + ok: false, + }, }); } finally { - setIsReloading(false); + dispatch({ type: "setIsReloading", value: false }); } - }; + }, [reload]); - const handleRestart = async (): Promise => { - setIsRestarting(true); - setMsg(null); + const handleRestart = useCallback(async (): Promise => { + dispatch({ type: "setIsRestarting", value: true }); + dispatch({ type: "setMsg", value: null }); try { await restart(); - setMsg({ text: "fail2ban restart initiated", ok: true }); + dispatch({ + type: "setMsg", + value: { text: "fail2ban restart initiated", ok: true }, + }); } catch (err: unknown) { - setMsg({ - text: err instanceof ApiError ? err.message : "Restart failed.", - ok: false, + dispatch({ + type: "setMsg", + value: { + text: err instanceof ApiError ? err.message : "Restart failed.", + ok: false, + }, }); } finally { - setIsRestarting(false); + dispatch({ type: "setIsRestarting", value: false }); } - }; + }, [restart]); useEffect(() => { if (!mapThresholds) return; - setMapThresholdHigh(String(mapThresholds.threshold_high)); - setMapThresholdMedium(String(mapThresholds.threshold_medium)); - setMapThresholdLow(String(mapThresholds.threshold_low)); + dispatch({ + type: "setMapThresholds", + high: String(mapThresholds.threshold_high), + medium: String(mapThresholds.threshold_medium), + low: String(mapThresholds.threshold_low), + }); }, [mapThresholds]); - // Map threshold validation and auto-save. const mapHigh = Number(mapThresholdHigh); const mapMedium = Number(mapThresholdMedium); const mapLow = Number(mapThresholdLow); @@ -160,18 +269,15 @@ export function ServerTab(): React.JSX.Element { return null; }, [mapHigh, mapMedium, mapLow, mapThresholds]); - const [mapValidPayload, setMapValidPayload] = useState({ - 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, + dispatch({ + type: "setMapValidPayload", + payload: { + threshold_high: mapHigh, + threshold_medium: mapMedium, + threshold_low: mapLow, + }, }); }, [mapHigh, mapMedium, mapLow, mapValidationError, mapThresholds]); @@ -224,7 +330,7 @@ export function ServerTab(): React.JSX.Element {