Stage 6: jail management — backend service, routers, tests, and frontend
- jail_service.py: list/detail/control/ban/unban/ignore-list/IP-lookup - jails.py router: 11 endpoints including ignore list management - bans.py router: active bans, ban, unban - geo.py router: IP lookup with geo enrichment - models: Jail.actions, ActiveBan.country/.banned_at optional, GeoDetail - 217 tests pass (40 service + 36 router + 141 existing), 76% coverage - Frontend: types/jail.ts, api/jails.ts, hooks/useJails.ts - JailsPage: jail overview table with controls, ban/unban forms, active bans table, IP lookup - JailDetailPage: full detail, start/stop/idle/reload, patterns, ignore list management
This commit is contained in:
358
frontend/src/hooks/useJails.ts
Normal file
358
frontend/src/hooks/useJails.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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 {
|
||||
addIgnoreIp,
|
||||
banIp,
|
||||
delIgnoreIp,
|
||||
fetchActiveBans,
|
||||
fetchJail,
|
||||
fetchJails,
|
||||
lookupIp,
|
||||
reloadAllJails,
|
||||
reloadJail,
|
||||
setJailIdle,
|
||||
startJail,
|
||||
stopJail,
|
||||
unbanIp,
|
||||
} from "../api/jails";
|
||||
import type {
|
||||
ActiveBan,
|
||||
IpLookupResponse,
|
||||
Jail,
|
||||
JailSummary,
|
||||
} 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) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
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(() => load()),
|
||||
reloadJail: withRefresh(reloadJail),
|
||||
reloadAll: () => reloadAllJails().then(() => 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, [name]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
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();
|
||||
};
|
||||
|
||||
return {
|
||||
jail,
|
||||
ignoreList,
|
||||
ignoreSelf,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
addIp,
|
||||
removeIp,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
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();
|
||||
};
|
||||
|
||||
return {
|
||||
bans,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
refresh: load,
|
||||
banIp: doBan,
|
||||
unbanIp: doUnban,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) => {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { result, loading, error, lookup, clear };
|
||||
}
|
||||
Reference in New Issue
Block a user