From a0e8566ff8e1ff4072696e1e3a77d2f216ccee36 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 13 Mar 2026 13:47:55 +0100 Subject: [PATCH] feat(frontend): add config hooks for jail, action, filter, and auto-save - useJailFileConfig: manages jail.local section state with dirty tracking - useActionConfig: manages action .conf file state - useFilterConfig: manages filter .conf file state - useAutoSave: debounced auto-save with status indicator support --- frontend/src/hooks/useActionConfig.ts | 89 ++++++++++++++++++ frontend/src/hooks/useAutoSave.ts | 119 ++++++++++++++++++++++++ frontend/src/hooks/useFilterConfig.ts | 91 ++++++++++++++++++ frontend/src/hooks/useJailFileConfig.ts | 76 +++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 frontend/src/hooks/useActionConfig.ts create mode 100644 frontend/src/hooks/useAutoSave.ts create mode 100644 frontend/src/hooks/useFilterConfig.ts create mode 100644 frontend/src/hooks/useJailFileConfig.ts diff --git a/frontend/src/hooks/useActionConfig.ts b/frontend/src/hooks/useActionConfig.ts new file mode 100644 index 0000000..4c2a32f --- /dev/null +++ b/frontend/src/hooks/useActionConfig.ts @@ -0,0 +1,89 @@ +/** + * React hook for loading and updating a single parsed action config. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchParsedAction, updateParsedAction } from "../api/config"; +import type { ActionConfig, ActionConfigUpdate } from "../types/config"; + +export interface UseActionConfigResult { + config: ActionConfig | null; + loading: boolean; + error: string | null; + saving: boolean; + saveError: string | null; + refresh: () => void; + save: (update: ActionConfigUpdate) => Promise; +} + +/** + * Load one action config by name and expose a ``save`` callback for partial + * updates. + * + * @param name - Action base name (e.g. ``"iptables"``). + */ +export function useActionConfig(name: string): UseActionConfigResult { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const abortRef = useRef(null); + + const load = useCallback((): void => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + setError(null); + + fetchParsedAction(name) + .then((data) => { + if (!ctrl.signal.aborted) { + setConfig(data); + setLoading(false); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + setError(err instanceof Error ? err.message : "Failed to load action config"); + setLoading(false); + } + }); + }, [name]); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const save = useCallback( + async (update: ActionConfigUpdate): Promise => { + setSaving(true); + setSaveError(null); + try { + await updateParsedAction(name, update); + setConfig((prev) => + prev + ? { + ...prev, + ...Object.fromEntries( + Object.entries(update).filter(([, v]) => v !== null && v !== undefined) + ), + } + : prev + ); + } catch (err: unknown) { + setSaveError(err instanceof Error ? err.message : "Failed to save action config"); + throw err; + } finally { + setSaving(false); + } + }, + [name] + ); + + return { config, loading, error, saving, saveError, refresh: load, save }; +} diff --git a/frontend/src/hooks/useAutoSave.ts b/frontend/src/hooks/useAutoSave.ts new file mode 100644 index 0000000..8c849f3 --- /dev/null +++ b/frontend/src/hooks/useAutoSave.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type AutoSaveStatus = "idle" | "saving" | "saved" | "error"; + +interface UseAutoSaveOptions { + /** Debounce delay in ms. Default: 800. */ + debounceMs?: number; + /** Equality check. Default: JSON.stringify comparison. */ + isEqual?: (a: T, b: T) => boolean; +} + +interface UseAutoSaveResult { + status: AutoSaveStatus; + errorText: string | null; + retry: () => void; +} + +const defaultIsEqual = (a: T, b: T): boolean => + JSON.stringify(a) === JSON.stringify(b); + +export function useAutoSave( + value: T, + save: (value: T) => Promise, + options?: UseAutoSaveOptions, +): UseAutoSaveResult { + const [status, setStatus] = useState("idle"); + const [errorText, setErrorText] = useState(null); + + // Refs to avoid stale closures — option refs are intentionally NOT in deps. + const debounceRef = useRef(options?.debounceMs ?? 800); + const isEqualRef = useRef<(a: T, b: T) => boolean>( + options?.isEqual ?? defaultIsEqual, + ); + const savedValueRef = useRef(value); + const latestValueRef = useRef(value); + const isSavingRef = useRef(false); + const pendingSaveRef = useRef(false); + const debounceTimerRef = useRef | null>(null); + const savedTimerRef = useRef | null>(null); + const saveRef = useRef(save); + + // Keep mutable refs up to date without triggering effects. + saveRef.current = save; + latestValueRef.current = value; + + const performSave = useCallback(async (valueToSave: T): Promise => { + if (isSavingRef.current) { + pendingSaveRef.current = true; + return; + } + + isSavingRef.current = true; + pendingSaveRef.current = false; + setStatus("saving"); + setErrorText(null); + + try { + await saveRef.current(valueToSave); + savedValueRef.current = valueToSave; + setStatus("saved"); + + if (savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current); + } + savedTimerRef.current = setTimeout(() => { + setStatus("idle"); + savedTimerRef.current = null; + }, 2000); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setErrorText(msg); + setStatus("error"); + } finally { + isSavingRef.current = false; + + // If a pending save was queued while we were saving, run it now. + if (pendingSaveRef.current as boolean) { + pendingSaveRef.current = false; + await performSave(latestValueRef.current); + } + } + }, []); + + // Debounce on value change. Only `value` and the stable `performSave` are + // deps — option refs are read directly to avoid re-running the effect when + // the caller recreates option callbacks. + useEffect(() => { + if (isEqualRef.current(value, savedValueRef.current)) { + return; + } + + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + void performSave(latestValueRef.current); + }, debounceRef.current); + }, [value, performSave]); + + // Cleanup timers on unmount. + useEffect(() => { + return (): void => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + if (savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current); + } + }; + }, []); + + const retry = useCallback(() => { + void performSave(latestValueRef.current); + }, [performSave]); + + return { status, errorText, retry }; +} diff --git a/frontend/src/hooks/useFilterConfig.ts b/frontend/src/hooks/useFilterConfig.ts new file mode 100644 index 0000000..9a52544 --- /dev/null +++ b/frontend/src/hooks/useFilterConfig.ts @@ -0,0 +1,91 @@ +/** + * React hook for loading and updating a single parsed filter config. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchParsedFilter, updateParsedFilter } from "../api/config"; +import type { FilterConfig, FilterConfigUpdate } from "../types/config"; + +export interface UseFilterConfigResult { + config: FilterConfig | null; + loading: boolean; + error: string | null; + saving: boolean; + saveError: string | null; + refresh: () => void; + save: (update: FilterConfigUpdate) => Promise; +} + +/** + * Load one filter config by name and expose a ``save`` callback for partial + * updates. + * + * @param name - Filter base name (e.g. ``"sshd"``). + */ +export function useFilterConfig(name: string): UseFilterConfigResult { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const abortRef = useRef(null); + + const load = useCallback((): void => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + setError(null); + + fetchParsedFilter(name) + .then((data) => { + if (!ctrl.signal.aborted) { + setConfig(data); + setLoading(false); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + setError(err instanceof Error ? err.message : "Failed to load filter config"); + setLoading(false); + } + }); + }, [name]); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const save = useCallback( + async (update: FilterConfigUpdate): Promise => { + setSaving(true); + setSaveError(null); + try { + await updateParsedFilter(name, update); + // Optimistically update local state so the form reflects changes + // without a full reload. + setConfig((prev) => + prev + ? { + ...prev, + ...Object.fromEntries( + Object.entries(update).filter(([, v]) => v !== null && v !== undefined) + ), + } + : prev + ); + } catch (err: unknown) { + setSaveError(err instanceof Error ? err.message : "Failed to save filter config"); + throw err; + } finally { + setSaving(false); + } + }, + [name] + ); + + return { config, loading, error, saving, saveError, refresh: load, save }; +} diff --git a/frontend/src/hooks/useJailFileConfig.ts b/frontend/src/hooks/useJailFileConfig.ts new file mode 100644 index 0000000..096df42 --- /dev/null +++ b/frontend/src/hooks/useJailFileConfig.ts @@ -0,0 +1,76 @@ +/** + * React hook for loading and updating a single parsed jail.d config file. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchParsedJailFile, updateParsedJailFile } from "../api/config"; +import type { JailFileConfig, JailFileConfigUpdate } from "../types/config"; + +export interface UseJailFileConfigResult { + config: JailFileConfig | null; + loading: boolean; + error: string | null; + refresh: () => void; + save: (update: JailFileConfigUpdate) => Promise; +} + +/** + * Load one jail.d config file by filename and expose a ``save`` callback for + * partial updates. + * + * @param filename - Filename including extension (e.g. ``"sshd.conf"``). + */ +export function useJailFileConfig(filename: string): UseJailFileConfigResult { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const load = useCallback((): void => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + setError(null); + + fetchParsedJailFile(filename) + .then((data) => { + if (!ctrl.signal.aborted) { + setConfig(data); + setLoading(false); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + setError(err instanceof Error ? err.message : "Failed to load jail file config"); + setLoading(false); + } + }); + }, [filename]); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const save = useCallback( + async (update: JailFileConfigUpdate): Promise => { + try { + await updateParsedJailFile(filename, update); + // Optimistically merge updated jails into local state. + if (update.jails != null) { + setConfig((prev) => + prev ? { ...prev, jails: { ...prev.jails, ...update.jails } } : prev + ); + } + } catch (err: unknown) { + throw err instanceof Error ? err : new Error("Failed to save jail file config"); + } + }, + [filename] + ); + + return { config, loading, error, refresh: load, save }; +}