/** * 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 }; }