diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 22b4369..4180693 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -198,6 +198,8 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. **Goal:** Split each multi-hook file so that every hook lives in its own file following the `hooks/.ts` naming convention. Create: `useBlocklists.ts`, `useSchedule.ts`, `useImportLog.ts`, `useRunImport.ts`, `useJailConfigs.ts`, `useJailConfigDetail.ts`, `useGlobalConfig.ts`, `useServerSettings.ts`, `useRegexTester.ts`, `useLogPreview.ts`, and whatever hooks are in `useJails.ts`. If hooks share internal utilities or types, extract those to a helper module — do not inline them in each new file. +**Status:** Completed. + **Possible traps and issues:** - All existing import sites must be updated. Each page and component imports specific hooks by name from these files; the import path changes but the imported name stays the same. - Hooks within the same file may share local helper functions or state logic that is not currently exported. Those helpers must be extracted into a shared internal module (e.g., `hooks/_configHelpers.ts`) or duplicated if they are truly trivial. diff --git a/frontend/src/hooks/useActiveBans.ts b/frontend/src/hooks/useActiveBans.ts new file mode 100644 index 0000000..cbb1b9f --- /dev/null +++ b/frontend/src/hooks/useActiveBans.ts @@ -0,0 +1,90 @@ +/** + * React hook for live active ban list management. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { banIp, fetchActiveBans, unbanAllBans, unbanIp } from "../api/jails"; +import { handleFetchError } from "../utils/fetchError"; +import type { ActiveBan, UnbanAllResponse } from "../types/jail"; + +export interface UseActiveBansResult { + bans: ActiveBan[]; + total: number; + loading: boolean; + error: string | null; + refresh: () => void; + banIp: (jail: string, ip: string) => Promise; + unbanIp: (ip: string, jail?: string) => Promise; + unbanAll: () => Promise; +} + +/** + * Fetch and manage the currently-active ban list. + */ +export function useActiveBans(): UseActiveBansResult { + const [bans, setBans] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const load = useCallback(() => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + setError(null); + + fetchActiveBans() + .then((res) => { + if (!ctrl.signal.aborted) { + setBans(res.bans); + setTotal(res.total); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to fetch active bans"); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) { + setLoading(false); + } + }); + }, []); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const doBan = useCallback(async (jail: string, ip: string): Promise => { + await banIp(jail, ip); + load(); + }, [load]); + + const doUnban = useCallback(async (ip: string, jail?: string): Promise => { + await unbanIp(ip, jail); + load(); + }, [load]); + + const doUnbanAll = useCallback(async (): Promise => { + const result = await unbanAllBans(); + load(); + return result; + }, [load]); + + return { + bans, + total, + loading, + error, + refresh: load, + banIp: doBan, + unbanIp: doUnban, + unbanAll: doUnbanAll, + }; +} diff --git a/frontend/src/hooks/useBlocklist.ts b/frontend/src/hooks/useBlocklist.ts index f539f54..7231f2f 100644 --- a/frontend/src/hooks/useBlocklist.ts +++ b/frontend/src/hooks/useBlocklist.ts @@ -1,302 +1,5 @@ -/** - * React hooks for blocklist management data fetching. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - createBlocklist, - deleteBlocklist, - fetchBlocklists, - fetchImportLog, - fetchSchedule, - previewBlocklist, - runImportNow, - updateBlocklist, - updateSchedule, -} from "../api/blocklist"; -import { handleFetchError } from "../utils/fetchError"; -import type { - BlocklistSource, - BlocklistSourceCreate, - BlocklistSourceUpdate, - ImportLogListResponse, - ImportRunResult, - PreviewResponse, - ScheduleConfig, - ScheduleInfo, -} from "../types/blocklist"; - -// --------------------------------------------------------------------------- -// useBlocklists -// --------------------------------------------------------------------------- - -export interface UseBlocklistsReturn { - sources: BlocklistSource[]; - loading: boolean; - error: string | null; - refresh: () => void; - createSource: (payload: BlocklistSourceCreate) => Promise; - updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise; - removeSource: (id: number) => Promise; - previewSource: (id: number) => Promise; -} - -/** - * Load all blocklist sources and expose CRUD operations. - */ -export function useBlocklists(): UseBlocklistsReturn { - const [sources, setSources] = useState([]); - 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); - - fetchBlocklists() - .then((data) => { - if (!ctrl.signal.aborted) { - setSources(data.sources); - setLoading(false); - } - }) - .catch((err: unknown) => { - if (!ctrl.signal.aborted) { - handleFetchError(err, setError, "Failed to load blocklists"); - setLoading(false); - } - }); - }, []); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const createSource = useCallback( - async (payload: BlocklistSourceCreate): Promise => { - const created = await createBlocklist(payload); - setSources((prev) => [...prev, created]); - return created; - }, - [], - ); - - const updateSource = useCallback( - async (id: number, payload: BlocklistSourceUpdate): Promise => { - const updated = await updateBlocklist(id, payload); - setSources((prev) => prev.map((s) => (s.id === id ? updated : s))); - return updated; - }, - [], - ); - - const removeSource = useCallback(async (id: number): Promise => { - await deleteBlocklist(id); - setSources((prev) => prev.filter((s) => s.id !== id)); - }, []); - - const previewSource = useCallback(async (id: number): Promise => { - return previewBlocklist(id); - }, []); - - return { - sources, - loading, - error, - refresh: load, - createSource, - updateSource, - removeSource, - previewSource, - }; -} - -// --------------------------------------------------------------------------- -// useSchedule -// --------------------------------------------------------------------------- - -export interface UseScheduleReturn { - info: ScheduleInfo | null; - loading: boolean; - error: string | null; - saveSchedule: (config: ScheduleConfig) => Promise; -} - -/** - * Fetch and update the blocklist import schedule. - */ -export function useSchedule(): UseScheduleReturn { - const [info, setInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - setLoading(true); - fetchSchedule() - .then((data) => { - setInfo(data); - setLoading(false); - }) - .catch((err: unknown) => { - handleFetchError(err, setError, "Failed to load schedule"); - setLoading(false); - }); - }, []); - - const saveSchedule = useCallback(async (config: ScheduleConfig): Promise => { - const updated = await updateSchedule(config); - setInfo(updated); - }, []); - - return { info, loading, error, saveSchedule }; -} - -// --------------------------------------------------------------------------- -// useImportLog -// --------------------------------------------------------------------------- - -export interface UseImportLogReturn { - data: ImportLogListResponse | null; - loading: boolean; - error: string | null; - page: number; - setPage: (n: number) => void; - refresh: () => void; -} - -/** - * Fetch the paginated import log with optional source filter. - */ -export function useImportLog( - sourceId?: number, - pageSize = 50, -): UseImportLogReturn { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [page, setPage] = useState(1); - const abortRef = useRef(null); - - const load = useCallback((): void => { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - - setLoading(true); - setError(null); - - fetchImportLog(page, pageSize, sourceId) - .then((result) => { - if (!ctrl.signal.aborted) { - setData(result); - setLoading(false); - } - }) - .catch((err: unknown) => { - if (!ctrl.signal.aborted) { - handleFetchError(err, setError, "Failed to load import log"); - setLoading(false); - } - }); - }, [page, pageSize, sourceId]); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - return { data, loading, error, page, setPage, refresh: load }; -} - -// --------------------------------------------------------------------------- -// useRunImport -// --------------------------------------------------------------------------- - -export interface UseRunImportReturn { - running: boolean; - lastResult: ImportRunResult | null; - error: string | null; - runNow: () => Promise; -} - -/** - * Trigger and track a manual blocklist import run. - */ -export function useRunImport(): UseRunImportReturn { - const [running, setRunning] = useState(false); - const [lastResult, setLastResult] = useState(null); - const [error, setError] = useState(null); - - const runNow = useCallback(async (): Promise => { - setRunning(true); - setError(null); - try { - const result = await runImportNow(); - setLastResult(result); - } catch (err: unknown) { - handleFetchError(err, setError, "Import failed"); - } finally { - setRunning(false); - } - }, []); - - return { running, lastResult, error, runNow }; -} - -// --------------------------------------------------------------------------- -// useBlocklistStatus -// --------------------------------------------------------------------------- - -/** How often to re-check the schedule endpoint for import errors (ms). */ -const BLOCKLIST_POLL_INTERVAL_MS = 60_000; - -export interface UseBlocklistStatusReturn { - /** `true` when the most recent import run completed with errors. */ - hasErrors: boolean; -} - -/** - * Poll `GET /api/blocklists/schedule` every 60 seconds to detect whether - * the most recent blocklist import had errors. - * - * Network failures during polling are silently ignored — the indicator - * simply retains its previous value until the next successful poll. - */ -export function useBlocklistStatus(): UseBlocklistStatusReturn { - const [hasErrors, setHasErrors] = useState(false); - - useEffect(() => { - let cancelled = false; - - const poll = (): void => { - fetchSchedule() - .then((info) => { - if (!cancelled) { - setHasErrors(info.last_run_errors === true); - } - }) - .catch(() => { - // Silently swallow network errors — do not change indicator state. - }); - }; - - poll(); - const id = window.setInterval(poll, BLOCKLIST_POLL_INTERVAL_MS); - return (): void => { - cancelled = true; - window.clearInterval(id); - }; - }, []); - - return { hasErrors }; -} +export { useBlocklists, type UseBlocklistsReturn } from "./useBlocklists"; +export { useSchedule, type UseScheduleReturn } from "./useSchedule"; +export { useImportLog, type UseImportLogReturn } from "./useImportLog"; +export { useRunImport, type UseRunImportReturn } from "./useRunImport"; +export { useBlocklistStatus, type UseBlocklistStatusReturn } from "./useBlocklistStatus"; diff --git a/frontend/src/hooks/useBlocklistStatus.ts b/frontend/src/hooks/useBlocklistStatus.ts new file mode 100644 index 0000000..ee810eb --- /dev/null +++ b/frontend/src/hooks/useBlocklistStatus.ts @@ -0,0 +1,45 @@ +/** + * React hook for polling blocklist schedule error state. + */ + +import { useEffect, useState } from "react"; +import { fetchSchedule } from "../api/blocklist"; + +const BLOCKLIST_POLL_INTERVAL_MS = 60_000; + +export interface UseBlocklistStatusReturn { + hasErrors: boolean; +} + +/** + * Poll `GET /api/blocklists/schedule` every 60 seconds to detect whether + * the most recent blocklist import had errors. + */ +export function useBlocklistStatus(): UseBlocklistStatusReturn { + const [hasErrors, setHasErrors] = useState(false); + + useEffect(() => { + let cancelled = false; + + const poll = (): void => { + fetchSchedule() + .then((info) => { + if (!cancelled) { + setHasErrors(info.last_run_errors === true); + } + }) + .catch(() => { + // Silently swallow network errors — do not change indicator state. + }); + }; + + poll(); + const id = window.setInterval(poll, BLOCKLIST_POLL_INTERVAL_MS); + return (): void => { + cancelled = true; + window.clearInterval(id); + }; + }, []); + + return { hasErrors }; +} diff --git a/frontend/src/hooks/useBlocklists.ts b/frontend/src/hooks/useBlocklists.ts new file mode 100644 index 0000000..0305754 --- /dev/null +++ b/frontend/src/hooks/useBlocklists.ts @@ -0,0 +1,108 @@ +/** + * React hook for listing and mutating blocklist sources. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + createBlocklist, + deleteBlocklist, + fetchBlocklists, + previewBlocklist, + updateBlocklist, +} from "../api/blocklist"; +import { handleFetchError } from "../utils/fetchError"; +import type { + BlocklistSource, + BlocklistSourceCreate, + BlocklistSourceUpdate, + PreviewResponse, +} from "../types/blocklist"; + +export interface UseBlocklistsReturn { + sources: BlocklistSource[]; + loading: boolean; + error: string | null; + refresh: () => void; + createSource: (payload: BlocklistSourceCreate) => Promise; + updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise; + removeSource: (id: number) => Promise; + previewSource: (id: number) => Promise; +} + +/** + * Load all blocklist sources and expose CRUD operations. + */ +export function useBlocklists(): UseBlocklistsReturn { + const [sources, setSources] = useState([]); + 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); + + fetchBlocklists() + .then((data) => { + if (!ctrl.signal.aborted) { + setSources(data.sources); + setLoading(false); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to load blocklists"); + setLoading(false); + } + }); + }, []); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const createSource = useCallback( + async (payload: BlocklistSourceCreate): Promise => { + const created = await createBlocklist(payload); + setSources((prev) => [...prev, created]); + return created; + }, + [], + ); + + const updateSource = useCallback( + async (id: number, payload: BlocklistSourceUpdate): Promise => { + const updated = await updateBlocklist(id, payload); + setSources((prev) => prev.map((s) => (s.id === id ? updated : s))); + return updated; + }, + [], + ); + + const removeSource = useCallback(async (id: number): Promise => { + await deleteBlocklist(id); + setSources((prev) => prev.filter((s) => s.id !== id)); + }, []); + + const previewSource = useCallback(async (id: number): Promise => { + return previewBlocklist(id); + }, []); + + return { + sources, + loading, + error, + refresh: load, + createSource, + updateSource, + removeSource, + previewSource, + }; +} diff --git a/frontend/src/hooks/useConfig.ts b/frontend/src/hooks/useConfig.ts index 4890520..391da97 100644 --- a/frontend/src/hooks/useConfig.ts +++ b/frontend/src/hooks/useConfig.ts @@ -1,365 +1,6 @@ -/** - * React hooks for the configuration and server settings data. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - addLogPath, - fetchGlobalConfig, - fetchJailConfig, - fetchJailConfigs, - previewLog, - reloadConfig, - restartFail2Ban, - testRegex, - updateGlobalConfig, - updateJailConfig, -} from "../api/config"; -import { - fetchServerSettings, - flushLogs, - updateServerSettings, -} from "../api/server"; -import { handleFetchError } from "../utils/fetchError"; -import type { - AddLogPathRequest, - GlobalConfig, - GlobalConfigUpdate, - JailConfig, - JailConfigUpdate, - LogPreviewRequest, - LogPreviewResponse, - RegexTestRequest, - RegexTestResponse, - ServerSettings, - ServerSettingsUpdate, -} from "../types/config"; - -// --------------------------------------------------------------------------- -// useJailConfigs — list all jail configs -// --------------------------------------------------------------------------- - -interface UseJailConfigsResult { - jails: JailConfig[]; - total: number; - loading: boolean; - error: string | null; - refresh: () => void; - updateJail: (name: string, update: JailConfigUpdate) => Promise; - reloadAll: () => Promise; -} - -export function useJailConfigs(): UseJailConfigsResult { - const [jails, setJails] = useState([]); - const [total, setTotal] = useState(0); - 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); - - fetchJailConfigs() - .then((resp) => { - setJails(resp.jails); - setTotal(resp.total); - }) - .catch((err: unknown) => { - handleFetchError(err, setError, "Failed to fetch jail configs"); - }) - .finally(() => { - setLoading(false); - }); - }, []); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const updateJail = useCallback( - async (name: string, update: JailConfigUpdate): Promise => { - await updateJailConfig(name, update); - load(); - }, - [load], - ); - - const reloadAll = useCallback(async (): Promise => { - await reloadConfig(); - load(); - }, [load]); - - return { jails, total, loading, error, refresh: load, updateJail, reloadAll }; -} - -// --------------------------------------------------------------------------- -// useJailConfigDetail — single jail config with mutation -// --------------------------------------------------------------------------- - -interface UseJailConfigDetailResult { - jail: JailConfig | null; - loading: boolean; - error: string | null; - refresh: () => void; - updateJail: (update: JailConfigUpdate) => Promise; - addLog: (req: AddLogPathRequest) => Promise; -} - -export function useJailConfigDetail(name: string): UseJailConfigDetailResult { - const [jail, setJail] = 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); - - fetchJailConfig(name) - .then((resp) => { - setJail(resp.jail); - }) - .catch((err: unknown) => { - handleFetchError(err, setError, "Failed to fetch jail config"); - }) - .finally(() => { - setLoading(false); - }); - }, [name]); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const updateJail = useCallback( - async (update: JailConfigUpdate): Promise => { - await updateJailConfig(name, update); - load(); - }, - [name, load], - ); - - const addLog = useCallback( - async (req: AddLogPathRequest): Promise => { - await addLogPath(name, req); - load(); - }, - [name, load], - ); - - return { jail, loading, error, refresh: load, updateJail, addLog }; -} - -// --------------------------------------------------------------------------- -// useGlobalConfig -// --------------------------------------------------------------------------- - -interface UseGlobalConfigResult { - config: GlobalConfig | null; - loading: boolean; - error: string | null; - refresh: () => void; - updateConfig: (update: GlobalConfigUpdate) => Promise; -} - -export function useGlobalConfig(): UseGlobalConfigResult { - 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); - - fetchGlobalConfig() - .then(setConfig) - .catch((err: unknown) => { - handleFetchError(err, setError, "Failed to fetch global config"); - }) - .finally(() => { - setLoading(false); - }); - }, []); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const updateConfig = useCallback( - async (update: GlobalConfigUpdate): Promise => { - await updateGlobalConfig(update); - load(); - }, - [load], - ); - - return { config, loading, error, refresh: load, updateConfig }; -} - -// --------------------------------------------------------------------------- -// useServerSettings -// --------------------------------------------------------------------------- - -interface UseServerSettingsResult { - settings: ServerSettings | null; - loading: boolean; - error: string | null; - refresh: () => void; - updateSettings: (update: ServerSettingsUpdate) => Promise; - flush: () => Promise; - reload: () => Promise; - restart: () => Promise; -} - -export function useServerSettings(): UseServerSettingsResult { - const [settings, setSettings] = 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); - - fetchServerSettings() - .then((resp) => { - setSettings(resp.settings); - }) - .catch((err: unknown) => { - handleFetchError(err, setError, "Failed to fetch server settings"); - }) - .finally(() => { - setLoading(false); - }); - }, []); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const updateSettings_ = useCallback( - async (update: ServerSettingsUpdate): Promise => { - await updateServerSettings(update); - load(); - }, - [load], - ); - - const reload = useCallback(async (): Promise => { - await reloadConfig(); - load(); - }, [load]); - - const restart = useCallback(async (): Promise => { - await restartFail2Ban(); - load(); - }, [load]); - - const flush = useCallback(async (): Promise => { - return flushLogs(); - }, []); - - return { - settings, - loading, - error, - refresh: load, - updateSettings: updateSettings_, - flush, - reload, - restart, - }; -} - -// --------------------------------------------------------------------------- -// useRegexTester — lazy, triggered by test(req) -// --------------------------------------------------------------------------- - -interface UseRegexTesterResult { - result: RegexTestResponse | null; - testing: boolean; - test: (req: RegexTestRequest) => Promise; -} - -export function useRegexTester(): UseRegexTesterResult { - const [result, setResult] = useState(null); - const [testing, setTesting] = useState(false); - - const test_ = useCallback(async (req: RegexTestRequest): Promise => { - setTesting(true); - try { - const resp = await testRegex(req); - setResult(resp); - } catch (err: unknown) { - if (err instanceof Error) { - setResult({ matched: false, groups: [], error: err.message }); - } - } finally { - setTesting(false); - } - }, []); - - return { result, testing, test: test_ }; -} - -// --------------------------------------------------------------------------- -// useLogPreview — lazy, triggered by preview(req) -// --------------------------------------------------------------------------- - -interface UseLogPreviewResult { - preview: LogPreviewResponse | null; - loading: boolean; - run: (req: LogPreviewRequest) => Promise; -} - -export function useLogPreview(): UseLogPreviewResult { - const [preview, setPreview] = useState(null); - const [loading, setLoading] = useState(false); - - const run_ = useCallback(async (req: LogPreviewRequest): Promise => { - setLoading(true); - try { - const resp = await previewLog(req); - setPreview(resp); - } catch (err: unknown) { - if (err instanceof Error) { - setPreview({ - lines: [], - total_lines: 0, - matched_count: 0, - regex_error: err.message, - }); - } - } finally { - setLoading(false); - } - }, []); - - return { preview, loading, run: run_ }; -} +export { useJailConfigs, type UseJailConfigsResult } from "./useJailConfigs"; +export { useJailConfigDetail, type UseJailConfigDetailResult } from "./useJailConfigDetail"; +export { useGlobalConfig, type UseGlobalConfigResult } from "./useGlobalConfig"; +export { useServerSettings, type UseServerSettingsResult } from "./useServerSettings"; +export { useRegexTester, type UseRegexTesterResult } from "./useRegexTester"; +export { useLogPreview, type UseLogPreviewResult } from "./useLogPreview"; diff --git a/frontend/src/hooks/useGlobalConfig.ts b/frontend/src/hooks/useGlobalConfig.ts new file mode 100644 index 0000000..2648ad6 --- /dev/null +++ b/frontend/src/hooks/useGlobalConfig.ts @@ -0,0 +1,68 @@ +/** + * React hook for loading and updating global configuration. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchGlobalConfig, updateGlobalConfig } from "../api/config"; +import { handleFetchError } from "../utils/fetchError"; +import type { GlobalConfig, GlobalConfigUpdate } from "../types/config"; + +export interface UseGlobalConfigResult { + config: GlobalConfig | null; + loading: boolean; + error: string | null; + refresh: () => void; + updateConfig: (update: GlobalConfigUpdate) => Promise; +} + +/** + * Load global configuration and expose update operations. + */ +export function useGlobalConfig(): UseGlobalConfigResult { + 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); + + fetchGlobalConfig() + .then((resp) => { + if (!ctrl.signal.aborted) { + setConfig(resp); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to fetch global config"); + } + }) + .finally(() => { + if (!abortRef.current?.signal.aborted) { + setLoading(false); + } + }); + }, []); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const updateConfig = useCallback( + async (update: GlobalConfigUpdate): Promise => { + await updateGlobalConfig(update); + load(); + }, + [load], + ); + + return { config, loading, error, refresh: load, updateConfig }; +} diff --git a/frontend/src/hooks/useImportLog.ts b/frontend/src/hooks/useImportLog.ts new file mode 100644 index 0000000..b666707 --- /dev/null +++ b/frontend/src/hooks/useImportLog.ts @@ -0,0 +1,63 @@ +/** + * React hook for loading paginated blocklist import log entries. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchImportLog } from "../api/blocklist"; +import { handleFetchError } from "../utils/fetchError"; +import type { ImportLogListResponse } from "../types/blocklist"; + +export interface UseImportLogReturn { + data: ImportLogListResponse | null; + loading: boolean; + error: string | null; + page: number; + setPage: (n: number) => void; + refresh: () => void; +} + +/** + * Fetch the paginated import log with optional source filter. + */ +export function useImportLog( + sourceId?: number, + pageSize = 50, +): UseImportLogReturn { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const abortRef = useRef(null); + + const load = useCallback((): void => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setLoading(true); + setError(null); + + fetchImportLog(page, pageSize, sourceId) + .then((result) => { + if (!ctrl.signal.aborted) { + setData(result); + setLoading(false); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to load import log"); + setLoading(false); + } + }); + }, [page, pageSize, sourceId]); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + return { data, loading, error, page, setPage, refresh: load }; +} diff --git a/frontend/src/hooks/useIpLookup.ts b/frontend/src/hooks/useIpLookup.ts new file mode 100644 index 0000000..ffbc74b --- /dev/null +++ b/frontend/src/hooks/useIpLookup.ts @@ -0,0 +1,49 @@ +/** + * React hook for looking up a single IP address. + */ + +import { useCallback, useState } from "react"; +import { handleFetchError } from "../utils/fetchError"; +import { lookupIp } from "../api/jails"; +import type { IpLookupResponse } from "../types/jail"; + +export interface UseIpLookupResult { + result: IpLookupResponse | null; + loading: boolean; + error: string | null; + lookup: (ip: string) => void; + clear: () => void; +} + +/** + * Manage IP lookup state and expose a lookup trigger. + */ +export function useIpLookup(): UseIpLookupResult { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const lookup = useCallback((ip: string) => { + setLoading(true); + setError(null); + setResult(null); + + lookupIp(ip) + .then((res) => { + setResult(res); + }) + .catch((err: unknown) => { + handleFetchError(err, setError, "Failed to lookup IP"); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const clear = useCallback(() => { + setResult(null); + setError(null); + }, []); + + return { result, loading, error, lookup, clear }; +} diff --git a/frontend/src/hooks/useJailBannedIps.ts b/frontend/src/hooks/useJailBannedIps.ts new file mode 100644 index 0000000..235fad4 --- /dev/null +++ b/frontend/src/hooks/useJailBannedIps.ts @@ -0,0 +1,106 @@ +/** + * React hook for paginated jailed IPs within a specific jail. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchJailBannedIps, unbanIp } from "../api/jails"; +import { handleFetchError } from "../utils/fetchError"; +import type { ActiveBan } from "../types/jail"; + +export interface UseJailBannedIpsResult { + items: ActiveBan[]; + total: number; + page: number; + pageSize: number; + search: string; + loading: boolean; + error: string | null; + opError: string | null; + refresh: () => Promise; + setPage: (page: number) => void; + setPageSize: (size: number) => void; + setSearch: (term: string) => void; + unban: (ip: string) => Promise; +} + +export function useJailBannedIps(jailName: string): UseJailBannedIpsResult { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [opError, setOpError] = useState(null); + const debounceRef = useRef | null>(null); + + const load = useCallback(async (): Promise => { + if (!jailName) { + setItems([]); + setTotal(0); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const resp = await fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined); + setItems(resp.items); + setTotal(resp.total); + } catch (err: unknown) { + handleFetchError(err, setError, "Failed to fetch jailed IPs"); + } finally { + setLoading(false); + } + }, [jailName, page, pageSize, debouncedSearch]); + + useEffect(() => { + if (debounceRef.current !== null) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + + return (): void => { + if (debounceRef.current !== null) { + clearTimeout(debounceRef.current); + } + }; + }, [search]); + + useEffect(() => { + void load(); + }, [load]); + + const unban = useCallback(async (ip: string): Promise => { + setOpError(null); + try { + await unbanIp(ip, jailName); + await load(); + } catch (err: unknown) { + setOpError(err instanceof Error ? err.message : String(err)); + } + }, [jailName, load]); + + return { + items, + total, + page, + pageSize, + search, + loading, + error, + opError, + refresh: load, + setPage, + setPageSize, + setSearch, + unban, + }; +} diff --git a/frontend/src/hooks/useJailConfigDetail.ts b/frontend/src/hooks/useJailConfigDetail.ts new file mode 100644 index 0000000..936eca6 --- /dev/null +++ b/frontend/src/hooks/useJailConfigDetail.ts @@ -0,0 +1,77 @@ +/** + * React hook for loading and mutating a single jail configuration. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { addLogPath, fetchJailConfig, updateJailConfig } from "../api/config"; +import { handleFetchError } from "../utils/fetchError"; +import type { AddLogPathRequest, JailConfig, JailConfigUpdate } from "../types/config"; + +export interface UseJailConfigDetailResult { + jail: JailConfig | null; + loading: boolean; + error: string | null; + refresh: () => void; + updateJail: (update: JailConfigUpdate) => Promise; + addLog: (req: AddLogPathRequest) => Promise; +} + +/** + * Load the detail view for a single jail config and expose edit actions. + */ +export function useJailConfigDetail(name: string): UseJailConfigDetailResult { + const [jail, setJail] = 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); + + fetchJailConfig(name) + .then((resp) => { + if (!ctrl.signal.aborted) { + setJail(resp.jail); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to fetch jail config"); + } + }) + .finally(() => { + if (!abortRef.current?.signal.aborted) { + setLoading(false); + } + }); + }, [name]); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const updateJail = useCallback( + async (update: JailConfigUpdate): Promise => { + await updateJailConfig(name, update); + load(); + }, + [name, load], + ); + + const addLog = useCallback( + async (req: AddLogPathRequest): Promise => { + await addLogPath(name, req); + load(); + }, + [name, load], + ); + + return { jail, loading, error, refresh: load, updateJail, addLog }; +} diff --git a/frontend/src/hooks/useJailConfigs.ts b/frontend/src/hooks/useJailConfigs.ts new file mode 100644 index 0000000..4db45e8 --- /dev/null +++ b/frontend/src/hooks/useJailConfigs.ts @@ -0,0 +1,77 @@ +/** + * React hook for loading the jail config inventory. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchJailConfigs, reloadConfig, updateJailConfig } from "../api/config"; +import { handleFetchError } from "../utils/fetchError"; +import type { JailConfig, JailConfigUpdate } from "../types/config"; + +export interface UseJailConfigsResult { + jails: JailConfig[]; + total: number; + loading: boolean; + error: string | null; + refresh: () => void; + updateJail: (name: string, update: JailConfigUpdate) => Promise; + reloadAll: () => Promise; +} + +/** + * Load all jail configs and expose update controls. + */ +export function useJailConfigs(): UseJailConfigsResult { + const [jails, setJails] = useState([]); + const [total, setTotal] = useState(0); + 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); + + fetchJailConfigs() + .then((resp) => { + if (!ctrl.signal.aborted) { + setJails(resp.jails); + setTotal(resp.total); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to fetch jail configs"); + } + }) + .finally(() => { + if (!abortRef.current?.signal.aborted) { + setLoading(false); + } + }); + }, []); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const updateJail = useCallback( + async (name: string, update: JailConfigUpdate): Promise => { + await updateJailConfig(name, update); + load(); + }, + [load], + ); + + const reloadAll = useCallback(async (): Promise => { + await reloadConfig(); + load(); + }, [load]); + + return { jails, total, loading, error, refresh: load, updateJail, reloadAll }; +} diff --git a/frontend/src/hooks/useJailDetail.ts b/frontend/src/hooks/useJailDetail.ts new file mode 100644 index 0000000..8385f49 --- /dev/null +++ b/frontend/src/hooks/useJailDetail.ts @@ -0,0 +1,121 @@ +/** + * React hook for fetching a single jail's detailed metadata. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { addIgnoreIp, delIgnoreIp, fetchJail, reloadJail, setJailIdle, startJail, stopJail, toggleIgnoreSelf as toggleIgnoreSelfApi } from "../api/jails"; +import { handleFetchError } from "../utils/fetchError"; +import type { Jail } from "../types/jail"; + +export interface UseJailDetailResult { + jail: Jail | null; + ignoreList: string[]; + ignoreSelf: boolean; + loading: boolean; + error: string | null; + refresh: () => void; + addIp: (ip: string) => Promise; + removeIp: (ip: string) => Promise; + toggleIgnoreSelf: (on: boolean) => Promise; + start: () => Promise; + stop: () => Promise; + reload: () => Promise; + setIdle: (on: boolean) => Promise; +} + +/** + * Fetch and manage the detail view for a single jail. + */ +export function useJailDetail(name: string): UseJailDetailResult { + const [jail, setJail] = useState(null); + const [ignoreList, setIgnoreList] = useState([]); + const [ignoreSelf, setIgnoreSelf] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const load = useCallback(() => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + setError(null); + + fetchJail(name) + .then((res) => { + if (!ctrl.signal.aborted) { + setJail(res.jail); + setIgnoreList(res.ignore_list); + setIgnoreSelf(res.ignore_self); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to fetch jail detail"); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) { + setLoading(false); + } + }); + }, [name]); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const addIp = useCallback(async (ip: string): Promise => { + await addIgnoreIp(name, ip); + load(); + }, [name, load]); + + const removeIp = useCallback(async (ip: string): Promise => { + await delIgnoreIp(name, ip); + load(); + }, [name, load]); + + const toggleIgnoreSelf = useCallback(async (on: boolean): Promise => { + await toggleIgnoreSelfApi(name, on); + load(); + }, [name, load]); + + const start = useCallback(async (): Promise => { + await startJail(name); + load(); + }, [name, load]); + + const stop = useCallback(async (): Promise => { + await stopJail(name); + load(); + }, [name, load]); + + const reload = useCallback(async (): Promise => { + await reloadJail(name); + load(); + }, [name, load]); + + const setIdle = useCallback(async (on: boolean): Promise => { + await setJailIdle(name, on); + load(); + }, [name, load]); + + return { + jail, + ignoreList, + ignoreSelf, + loading, + error, + refresh: load, + addIp, + removeIp, + toggleIgnoreSelf, + start, + stop, + reload, + setIdle, + }; +} diff --git a/frontend/src/hooks/useJailList.ts b/frontend/src/hooks/useJailList.ts new file mode 100644 index 0000000..483248a --- /dev/null +++ b/frontend/src/hooks/useJailList.ts @@ -0,0 +1,96 @@ +/** + * React hook for loading and controlling the jail overview list. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + fetchJails, + reloadAllJails, + reloadJail, + setJailIdle, + startJail, + stopJail, +} from "../api/jails"; +import { handleFetchError } from "../utils/fetchError"; +import type { JailSummary } from "../types/jail"; + +export interface UseJailsResult { + jails: JailSummary[]; + total: number; + loading: boolean; + error: string | null; + refresh: () => void; + startJail: (name: string) => Promise; + stopJail: (name: string) => Promise; + setIdle: (name: string, on: boolean) => Promise; + reloadJail: (name: string) => Promise; + reloadAll: () => Promise; +} + +/** + * Fetch and manage the jail overview list. + */ +export function useJails(): UseJailsResult { + const [jails, setJails] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const load = useCallback(() => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoading(true); + setError(null); + + fetchJails() + .then((res) => { + if (!ctrl.signal.aborted) { + setJails(res.jails); + setTotal(res.total); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to load jails"); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) { + setLoading(false); + } + }); + }, []); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const withRefresh = + (fn: (name: string) => Promise) => + async (name: string): Promise => { + await fn(name); + load(); + }; + + return { + jails, + total, + loading, + error, + refresh: load, + startJail: withRefresh(startJail), + stopJail: withRefresh(stopJail), + setIdle: (name, on) => setJailIdle(name, on).then(() => { + load(); + }), + reloadJail: withRefresh(reloadJail), + reloadAll: () => reloadAllJails().then(() => { + load(); + }), + }; +} diff --git a/frontend/src/hooks/useJails.ts b/frontend/src/hooks/useJails.ts index cb23962..88fdda7 100644 --- a/frontend/src/hooks/useJails.ts +++ b/frontend/src/hooks/useJails.ts @@ -1,513 +1,5 @@ -/** - * Jail management hooks. - * - * Provides data-fetching and mutation hooks for all jail-related views, - * following the same patterns established by `useBans.ts` and - * `useServerStatus.ts`. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; -import { handleFetchError } from "../utils/fetchError"; -import { - addIgnoreIp, - banIp, - delIgnoreIp, - fetchActiveBans, - fetchJail, - fetchJailBannedIps, - fetchJails, - lookupIp, - reloadAllJails, - reloadJail, - setJailIdle, - startJail, - stopJail, - toggleIgnoreSelf as toggleIgnoreSelfApi, - unbanAllBans, - unbanIp, -} from "../api/jails"; -import type { - ActiveBan, - IpLookupResponse, - Jail, - JailSummary, - UnbanAllResponse, -} from "../types/jail"; - -// --------------------------------------------------------------------------- -// useJails — overview list -// --------------------------------------------------------------------------- - -/** Return value for {@link useJails}. */ -export interface UseJailsResult { - /** All known jails. */ - jails: JailSummary[]; - /** Total count returned by the backend. */ - total: number; - /** `true` while a fetch is in progress. */ - loading: boolean; - /** Error message from the last failed fetch, or `null`. */ - error: string | null; - /** Re-fetch the jail list from the backend. */ - refresh: () => void; - /** Start a specific jail (returns a promise for error handling). */ - startJail: (name: string) => Promise; - /** Stop a specific jail. */ - stopJail: (name: string) => Promise; - /** Toggle idle mode for a jail. */ - setIdle: (name: string, on: boolean) => Promise; - /** Reload a specific jail. */ - reloadJail: (name: string) => Promise; - /** Reload all jails at once. */ - reloadAll: () => Promise; -} - -/** - * Fetch and manage the jail overview list. - * - * Automatically loads on mount and exposes control mutations that refresh - * the list after each operation. - * - * @returns Current jail list, loading/error state, and control callbacks. - */ -export function useJails(): UseJailsResult { - const [jails, setJails] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const abortRef = useRef(null); - - const load = useCallback(() => { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - setLoading(true); - setError(null); - - fetchJails() - .then((res) => { - if (!ctrl.signal.aborted) { - setJails(res.jails); - setTotal(res.total); - } - }) - .catch((err: unknown) => { - if (!ctrl.signal.aborted) { - handleFetchError(err, setError, "Failed to load jails"); - } - }) - .finally(() => { - if (!ctrl.signal.aborted) { - setLoading(false); - } - }); - }, []); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const withRefresh = - (fn: (name: string) => Promise) => - async (name: string): Promise => { - await fn(name); - load(); - }; - - return { - jails, - total, - loading, - error, - refresh: load, - startJail: withRefresh(startJail), - stopJail: withRefresh(stopJail), - setIdle: (name, on) => setJailIdle(name, on).then((): void => { load(); }), - reloadJail: withRefresh(reloadJail), - reloadAll: () => reloadAllJails().then((): void => { load(); }), - }; -} - -// --------------------------------------------------------------------------- -// useJailDetail — single jail -// --------------------------------------------------------------------------- - -/** Return value for {@link useJailDetail}. */ -export interface UseJailDetailResult { - /** Full jail configuration, or `null` while loading. */ - jail: Jail | null; - /** Current ignore list. */ - ignoreList: string[]; - /** Whether `ignoreself` is enabled. */ - ignoreSelf: boolean; - /** `true` while a fetch is in progress. */ - loading: boolean; - /** Error message or `null`. */ - error: string | null; - /** Re-fetch from the backend. */ - refresh: () => void; - /** Add an IP to the ignore list. */ - addIp: (ip: string) => Promise; - /** Remove an IP from the ignore list. */ - removeIp: (ip: string) => Promise; - /** Enable or disable the ignoreself option for this jail. */ - toggleIgnoreSelf: (on: boolean) => Promise; - /** Start the jail. */ - start: () => Promise; - /** Stop the jail. */ - stop: () => Promise; - /** Reload jail configuration. */ - reload: () => Promise; - /** Toggle idle mode on/off for the jail. */ - setIdle: (on: boolean) => Promise; -} - -/** - * Fetch and manage the detail view for a single jail. - * - * @param name - Jail name to load. - * @returns Jail detail, ignore list management helpers, and fetch state. - */ -export function useJailDetail(name: string): UseJailDetailResult { - const [jail, setJail] = useState(null); - const [ignoreList, setIgnoreList] = useState([]); - const [ignoreSelf, setIgnoreSelf] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const abortRef = useRef(null); - - const load = useCallback(() => { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - setLoading(true); - setError(null); - - fetchJail(name) - .then((res) => { - if (!ctrl.signal.aborted) { - setJail(res.jail); - setIgnoreList(res.ignore_list); - setIgnoreSelf(res.ignore_self); - } - }) - .catch((err: unknown) => { - if (!ctrl.signal.aborted) { - handleFetchError(err, setError, "Failed to fetch jail detail"); - } - }) - .finally(() => { - if (!ctrl.signal.aborted) setLoading(false); - }); - }, [name]); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const addIp = async (ip: string): Promise => { - await addIgnoreIp(name, ip); - load(); - }; - - const removeIp = async (ip: string): Promise => { - await delIgnoreIp(name, ip); - load(); - }; - - const toggleIgnoreSelf = async (on: boolean): Promise => { - await toggleIgnoreSelfApi(name, on); - load(); - }; - - const doStart = async (): Promise => { - await startJail(name); - load(); - }; - - const doStop = async (): Promise => { - await stopJail(name); - load(); - }; - - const doReload = async (): Promise => { - await reloadJail(name); - load(); - }; - - const doSetIdle = async (on: boolean): Promise => { - await setJailIdle(name, on); - load(); - }; - - return { - jail, - ignoreList, - ignoreSelf, - loading, - error, - refresh: load, - addIp, - removeIp, - toggleIgnoreSelf, - start: doStart, - stop: doStop, - reload: doReload, - setIdle: doSetIdle, - }; -} - -// --------------------------------------------------------------------------- -// useJailBannedIps - -export interface UseJailBannedIpsResult { - items: ActiveBan[]; - total: number; - page: number; - pageSize: number; - search: string; - loading: boolean; - error: string | null; - opError: string | null; - refresh: () => Promise; - setPage: (page: number) => void; - setPageSize: (size: number) => void; - setSearch: (term: string) => void; - unban: (ip: string) => Promise; -} - -export function useJailBannedIps(jailName: string): UseJailBannedIpsResult { - const [items, setItems] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(25); - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [opError, setOpError] = useState(null); - const debounceRef = useRef | null>(null); - - const load = useCallback(async (): Promise => { - if (!jailName) { - setItems([]); - setTotal(0); - setLoading(false); - return; - } - - setLoading(true); - setError(null); - - try { - const resp = await fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined); - setItems(resp.items); - setTotal(resp.total); - } catch (err: unknown) { - handleFetchError(err, setError, "Failed to fetch jailed IPs"); - } finally { - setLoading(false); - } - }, [jailName, page, pageSize, debouncedSearch]); - - useEffect(() => { - if (debounceRef.current !== null) { - clearTimeout(debounceRef.current); - } - debounceRef.current = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, 300); - - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - return () => { - if (debounceRef.current !== null) { - clearTimeout(debounceRef.current); - } - }; - }, [search]); - - useEffect(() => { - void load(); - }, [load]); - - const unban = useCallback(async (ip: string): Promise => { - setOpError(null); - try { - await unbanIp(ip, jailName); - await load(); - } catch (err: unknown) { - setOpError(err instanceof Error ? err.message : String(err)); - } - }, [jailName, load]); - - return { - items, - total, - page, - pageSize, - search, - loading, - error, - opError, - refresh: load, - setPage, - setPageSize, - setSearch, - unban, - }; -} - -// --------------------------------------------------------------------------- -// useActiveBans — live ban list -// --------------------------------------------------------------------------- - -/** Return value for {@link useActiveBans}. */ -export interface UseActiveBansResult { - /** All currently active bans. */ - bans: ActiveBan[]; - /** Total ban count. */ - total: number; - /** `true` while fetching. */ - loading: boolean; - /** Error message or `null`. */ - error: string | null; - /** Re-fetch the active bans. */ - refresh: () => void; - /** Ban an IP in a specific jail. */ - banIp: (jail: string, ip: string) => Promise; - /** Unban an IP from a jail (or all jails when `jail` is omitted). */ - unbanIp: (ip: string, jail?: string) => Promise; - /** Unban every currently banned IP across all jails. */ - unbanAll: () => Promise; -} - -/** - * Fetch and manage the currently-active ban list. - * - * @returns Active ban list, mutation callbacks, and fetch state. - */ -export function useActiveBans(): UseActiveBansResult { - const [bans, setBans] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const abortRef = useRef(null); - - const load = useCallback(() => { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - setLoading(true); - setError(null); - - fetchActiveBans() - .then((res) => { - if (!ctrl.signal.aborted) { - setBans(res.bans); - setTotal(res.total); - } - }) - .catch((err: unknown) => { - if (!ctrl.signal.aborted) { - handleFetchError(err, setError, "Failed to fetch active bans"); - } - }) - .finally(() => { - if (!ctrl.signal.aborted) setLoading(false); - }); - }, []); - - useEffect(() => { - load(); - return (): void => { - abortRef.current?.abort(); - }; - }, [load]); - - const doBan = async (jail: string, ip: string): Promise => { - await banIp(jail, ip); - load(); - }; - - const doUnban = async (ip: string, jail?: string): Promise => { - await unbanIp(ip, jail); - load(); - }; - - const doUnbanAll = async (): Promise => { - const result = await unbanAllBans(); - load(); - return result; - }; - - return { - bans, - total, - loading, - error, - refresh: load, - banIp: doBan, - unbanIp: doUnban, - unbanAll: doUnbanAll, - }; -} - -// --------------------------------------------------------------------------- -// useIpLookup — single IP lookup -// --------------------------------------------------------------------------- - -/** Return value for {@link useIpLookup}. */ -export interface UseIpLookupResult { - /** Lookup result, or `null` when no lookup has been performed yet. */ - result: IpLookupResponse | null; - /** `true` while a lookup is in progress. */ - loading: boolean; - /** Error message or `null`. */ - error: string | null; - /** Trigger an IP lookup. */ - lookup: (ip: string) => void; - /** Clear the result and error state. */ - clear: () => void; -} - -/** - * Manage IP lookup state (lazy — no fetch on mount). - * - * @returns Lookup result, state flags, and a `lookup` trigger callback. - */ -export function useIpLookup(): UseIpLookupResult { - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const lookup = useCallback((ip: string) => { - setLoading(true); - setError(null); - setResult(null); - - lookupIp(ip) - .then((res) => { - setResult(res); - }) - .catch((err: unknown) => { - handleFetchError(err, setError, "Failed to lookup IP"); - }) - .finally(() => { - setLoading(false); - }); - }, []); - - const clear = useCallback(() => { - setResult(null); - setError(null); - }, []); - - return { result, loading, error, lookup, clear }; -} +export { useJails, type UseJailsResult } from "./useJailList"; +export { useJailDetail, type UseJailDetailResult } from "./useJailDetail"; +export { useJailBannedIps, type UseJailBannedIpsResult } from "./useJailBannedIps"; +export { useActiveBans, type UseActiveBansResult } from "./useActiveBans"; +export { useIpLookup, type UseIpLookupResult } from "./useIpLookup"; diff --git a/frontend/src/hooks/useLogPreview.ts b/frontend/src/hooks/useLogPreview.ts new file mode 100644 index 0000000..a8b5f66 --- /dev/null +++ b/frontend/src/hooks/useLogPreview.ts @@ -0,0 +1,42 @@ +/** + * React hook for fetching a server log preview. + */ + +import { useCallback, useState } from "react"; +import { previewLog } from "../api/config"; +import type { LogPreviewRequest, LogPreviewResponse } from "../types/config"; + +export interface UseLogPreviewResult { + preview: LogPreviewResponse | null; + loading: boolean; + run: (req: LogPreviewRequest) => Promise; +} + +/** + * Execute a log preview and expose the response state. + */ +export function useLogPreview(): UseLogPreviewResult { + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + + const run = useCallback(async (req: LogPreviewRequest): Promise => { + setLoading(true); + try { + const resp = await previewLog(req); + setPreview(resp); + } catch (err: unknown) { + if (err instanceof Error) { + setPreview({ + lines: [], + total_lines: 0, + matched_count: 0, + regex_error: err.message, + }); + } + } finally { + setLoading(false); + } + }, []); + + return { preview, loading, run }; +} diff --git a/frontend/src/hooks/useRegexTester.ts b/frontend/src/hooks/useRegexTester.ts new file mode 100644 index 0000000..935798e --- /dev/null +++ b/frontend/src/hooks/useRegexTester.ts @@ -0,0 +1,37 @@ +/** + * React hook for running a regex test request. + */ + +import { useCallback, useState } from "react"; +import { testRegex } from "../api/config"; +import type { RegexTestRequest, RegexTestResponse } from "../types/config"; + +export interface UseRegexTesterResult { + result: RegexTestResponse | null; + testing: boolean; + test: (req: RegexTestRequest) => Promise; +} + +/** + * Execute regex tests and expose the current response. + */ +export function useRegexTester(): UseRegexTesterResult { + const [result, setResult] = useState(null); + const [testing, setTesting] = useState(false); + + const test = useCallback(async (req: RegexTestRequest): Promise => { + setTesting(true); + try { + const resp = await testRegex(req); + setResult(resp); + } catch (err: unknown) { + if (err instanceof Error) { + setResult({ matched: false, groups: [], error: err.message }); + } + } finally { + setTesting(false); + } + }, []); + + return { result, testing, test }; +} diff --git a/frontend/src/hooks/useRunImport.ts b/frontend/src/hooks/useRunImport.ts new file mode 100644 index 0000000..9c918fa --- /dev/null +++ b/frontend/src/hooks/useRunImport.ts @@ -0,0 +1,39 @@ +/** + * React hook for triggering a manual blocklist import run. + */ + +import { useCallback, useState } from "react"; +import { runImportNow } from "../api/blocklist"; +import { handleFetchError } from "../utils/fetchError"; +import type { ImportRunResult } from "../types/blocklist"; + +export interface UseRunImportReturn { + running: boolean; + lastResult: ImportRunResult | null; + error: string | null; + runNow: () => Promise; +} + +/** + * Trigger and track a manual blocklist import run. + */ +export function useRunImport(): UseRunImportReturn { + const [running, setRunning] = useState(false); + const [lastResult, setLastResult] = useState(null); + const [error, setError] = useState(null); + + const runNow = useCallback(async (): Promise => { + setRunning(true); + setError(null); + try { + const result = await runImportNow(); + setLastResult(result); + } catch (err: unknown) { + handleFetchError(err, setError, "Import failed"); + } finally { + setRunning(false); + } + }, []); + + return { running, lastResult, error, runNow }; +} diff --git a/frontend/src/hooks/useSchedule.ts b/frontend/src/hooks/useSchedule.ts new file mode 100644 index 0000000..8a5c269 --- /dev/null +++ b/frontend/src/hooks/useSchedule.ts @@ -0,0 +1,44 @@ +/** + * React hook for fetching and updating the blocklist import schedule. + */ + +import { useCallback, useEffect, useState } from "react"; +import { fetchSchedule, updateSchedule } from "../api/blocklist"; +import { handleFetchError } from "../utils/fetchError"; +import type { ScheduleConfig, ScheduleInfo } from "../types/blocklist"; + +export interface UseScheduleReturn { + info: ScheduleInfo | null; + loading: boolean; + error: string | null; + saveSchedule: (config: ScheduleConfig) => Promise; +} + +/** + * Fetch and update the blocklist import schedule. + */ +export function useSchedule(): UseScheduleReturn { + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + fetchSchedule() + .then((data) => { + setInfo(data); + setLoading(false); + }) + .catch((err: unknown) => { + handleFetchError(err, setError, "Failed to load schedule"); + setLoading(false); + }); + }, []); + + const saveSchedule = useCallback(async (config: ScheduleConfig): Promise => { + const updated = await updateSchedule(config); + setInfo(updated); + }, []); + + return { info, loading, error, saveSchedule }; +} diff --git a/frontend/src/hooks/useServerSettings.ts b/frontend/src/hooks/useServerSettings.ts new file mode 100644 index 0000000..891f713 --- /dev/null +++ b/frontend/src/hooks/useServerSettings.ts @@ -0,0 +1,95 @@ +/** + * React hook for loading and mutating server settings and controls. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchServerSettings, flushLogs, updateServerSettings } from "../api/server"; +import { reloadConfig, restartFail2Ban } from "../api/config"; +import { handleFetchError } from "../utils/fetchError"; +import type { ServerSettings, ServerSettingsUpdate } from "../types/config"; + +export interface UseServerSettingsResult { + settings: ServerSettings | null; + loading: boolean; + error: string | null; + refresh: () => void; + updateSettings: (update: ServerSettingsUpdate) => Promise; + flush: () => Promise; + reload: () => Promise; + restart: () => Promise; +} + +/** + * Load server settings and provide control actions. + */ +export function useServerSettings(): UseServerSettingsResult { + const [settings, setSettings] = 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); + + fetchServerSettings() + .then((resp) => { + if (!ctrl.signal.aborted) { + setSettings(resp.settings); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setError, "Failed to fetch server settings"); + } + }) + .finally(() => { + if (!abortRef.current?.signal.aborted) { + setLoading(false); + } + }); + }, []); + + useEffect(() => { + load(); + return (): void => { + abortRef.current?.abort(); + }; + }, [load]); + + const updateSettings = useCallback( + async (update: ServerSettingsUpdate): Promise => { + await updateServerSettings(update); + load(); + }, + [load], + ); + + const reload = useCallback(async (): Promise => { + await reloadConfig(); + load(); + }, [load]); + + const restart = useCallback(async (): Promise => { + await restartFail2Ban(); + load(); + }, [load]); + + const flush = useCallback(async (): Promise => { + return flushLogs(); + }, []); + + return { + settings, + loading, + error, + refresh: load, + updateSettings, + flush, + reload, + restart, + }; +}