Files
BanGUI/frontend/src/hooks/useJails.ts

514 lines
13 KiB
TypeScript

/**
* 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<void>;
/** Stop a specific jail. */
stopJail: (name: string) => Promise<void>;
/** Toggle idle mode for a jail. */
setIdle: (name: string, on: boolean) => Promise<void>;
/** Reload a specific jail. */
reloadJail: (name: string) => Promise<void>;
/** Reload all jails at once. */
reloadAll: () => Promise<void>;
}
/**
* 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<JailSummary[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<unknown>) =>
async (name: string): Promise<void> => {
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<void>;
/** Remove an IP from the ignore list. */
removeIp: (ip: string) => Promise<void>;
/** Enable or disable the ignoreself option for this jail. */
toggleIgnoreSelf: (on: boolean) => Promise<void>;
/** Start the jail. */
start: () => Promise<void>;
/** Stop the jail. */
stop: () => Promise<void>;
/** Reload jail configuration. */
reload: () => Promise<void>;
/** Toggle idle mode on/off for the jail. */
setIdle: (on: boolean) => Promise<void>;
}
/**
* 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<Jail | null>(null);
const [ignoreList, setIgnoreList] = useState<string[]>([]);
const [ignoreSelf, setIgnoreSelf] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await addIgnoreIp(name, ip);
load();
};
const removeIp = async (ip: string): Promise<void> => {
await delIgnoreIp(name, ip);
load();
};
const toggleIgnoreSelf = async (on: boolean): Promise<void> => {
await toggleIgnoreSelfApi(name, on);
load();
};
const doStart = async (): Promise<void> => {
await startJail(name);
load();
};
const doStop = async (): Promise<void> => {
await stopJail(name);
load();
};
const doReload = async (): Promise<void> => {
await reloadJail(name);
load();
};
const doSetIdle = async (on: boolean): Promise<void> => {
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<void>;
setPage: (page: number) => void;
setPageSize: (size: number) => void;
setSearch: (term: string) => void;
unban: (ip: string) => Promise<void>;
}
export function useJailBannedIps(jailName: string): UseJailBannedIpsResult {
const [items, setItems] = useState<ActiveBan[]>([]);
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<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(async (): Promise<void> => {
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<void> => {
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<void>;
/** Unban an IP from a jail (or all jails when `jail` is omitted). */
unbanIp: (ip: string, jail?: string) => Promise<void>;
/** Unban every currently banned IP across all jails. */
unbanAll: () => Promise<UnbanAllResponse>;
}
/**
* 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<ActiveBan[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await banIp(jail, ip);
load();
};
const doUnban = async (ip: string, jail?: string): Promise<void> => {
await unbanIp(ip, jail);
load();
};
const doUnbanAll = async (): Promise<UnbanAllResponse> => {
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<IpLookupResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}