514 lines
13 KiB
TypeScript
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 };
|
|
}
|