Split multi-hook frontend modules into single-hook files

This commit is contained in:
2026-04-18 20:47:44 +02:00
parent fba7675eb8
commit 3f197b1ad7
20 changed files with 1175 additions and 1180 deletions

View File

@@ -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/<hookName>.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.

View File

@@ -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<void>;
unbanIp: (ip: string, jail?: string) => Promise<void>;
unbanAll: () => Promise<UnbanAllResponse>;
}
/**
* Fetch and manage the currently-active ban list.
*/
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 = useCallback(async (jail: string, ip: string): Promise<void> => {
await banIp(jail, ip);
load();
}, [load]);
const doUnban = useCallback(async (ip: string, jail?: string): Promise<void> => {
await unbanIp(ip, jail);
load();
}, [load]);
const doUnbanAll = useCallback(async (): Promise<UnbanAllResponse> => {
const result = await unbanAllBans();
load();
return result;
}, [load]);
return {
bans,
total,
loading,
error,
refresh: load,
banIp: doBan,
unbanIp: doUnban,
unbanAll: doUnbanAll,
};
}

View File

@@ -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<BlocklistSource>;
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;
removeSource: (id: number) => Promise<void>;
previewSource: (id: number) => Promise<PreviewResponse>;
}
/**
* Load all blocklist sources and expose CRUD operations.
*/
export function useBlocklists(): UseBlocklistsReturn {
const [sources, setSources] = useState<BlocklistSource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<BlocklistSource> => {
const created = await createBlocklist(payload);
setSources((prev) => [...prev, created]);
return created;
},
[],
);
const updateSource = useCallback(
async (id: number, payload: BlocklistSourceUpdate): Promise<BlocklistSource> => {
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<void> => {
await deleteBlocklist(id);
setSources((prev) => prev.filter((s) => s.id !== id));
}, []);
const previewSource = useCallback(async (id: number): Promise<PreviewResponse> => {
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<void>;
}
/**
* Fetch and update the blocklist import schedule.
*/
export function useSchedule(): UseScheduleReturn {
const [info, setInfo] = useState<ScheduleInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<void> => {
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<ImportLogListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const abortRef = useRef<AbortController | null>(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<void>;
}
/**
* Trigger and track a manual blocklist import run.
*/
export function useRunImport(): UseRunImportReturn {
const [running, setRunning] = useState(false);
const [lastResult, setLastResult] = useState<ImportRunResult | null>(null);
const [error, setError] = useState<string | null>(null);
const runNow = useCallback(async (): Promise<void> => {
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";

View File

@@ -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 };
}

View File

@@ -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<BlocklistSource>;
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;
removeSource: (id: number) => Promise<void>;
previewSource: (id: number) => Promise<PreviewResponse>;
}
/**
* Load all blocklist sources and expose CRUD operations.
*/
export function useBlocklists(): UseBlocklistsReturn {
const [sources, setSources] = useState<BlocklistSource[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<BlocklistSource> => {
const created = await createBlocklist(payload);
setSources((prev) => [...prev, created]);
return created;
},
[],
);
const updateSource = useCallback(
async (id: number, payload: BlocklistSourceUpdate): Promise<BlocklistSource> => {
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<void> => {
await deleteBlocklist(id);
setSources((prev) => prev.filter((s) => s.id !== id));
}, []);
const previewSource = useCallback(async (id: number): Promise<PreviewResponse> => {
return previewBlocklist(id);
}, []);
return {
sources,
loading,
error,
refresh: load,
createSource,
updateSource,
removeSource,
previewSource,
};
}

View File

@@ -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<void>;
reloadAll: () => Promise<void>;
}
export function useJailConfigs(): UseJailConfigsResult {
const [jails, setJails] = useState<JailConfig[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await updateJailConfig(name, update);
load();
},
[load],
);
const reloadAll = useCallback(async (): Promise<void> => {
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<void>;
addLog: (req: AddLogPathRequest) => Promise<void>;
}
export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
const [jail, setJail] = useState<JailConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await updateJailConfig(name, update);
load();
},
[name, load],
);
const addLog = useCallback(
async (req: AddLogPathRequest): Promise<void> => {
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<void>;
}
export function useGlobalConfig(): UseGlobalConfigResult {
const [config, setConfig] = useState<GlobalConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
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<void>;
flush: () => Promise<string>;
reload: () => Promise<void>;
restart: () => Promise<void>;
}
export function useServerSettings(): UseServerSettingsResult {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await updateServerSettings(update);
load();
},
[load],
);
const reload = useCallback(async (): Promise<void> => {
await reloadConfig();
load();
}, [load]);
const restart = useCallback(async (): Promise<void> => {
await restartFail2Ban();
load();
}, [load]);
const flush = useCallback(async (): Promise<string> => {
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<void>;
}
export function useRegexTester(): UseRegexTesterResult {
const [result, setResult] = useState<RegexTestResponse | null>(null);
const [testing, setTesting] = useState(false);
const test_ = useCallback(async (req: RegexTestRequest): Promise<void> => {
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<void>;
}
export function useLogPreview(): UseLogPreviewResult {
const [preview, setPreview] = useState<LogPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const run_ = useCallback(async (req: LogPreviewRequest): Promise<void> => {
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";

View File

@@ -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<void>;
}
/**
* Load global configuration and expose update operations.
*/
export function useGlobalConfig(): UseGlobalConfigResult {
const [config, setConfig] = useState<GlobalConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await updateGlobalConfig(update);
load();
},
[load],
);
return { config, loading, error, refresh: load, updateConfig };
}

View File

@@ -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<ImportLogListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const abortRef = useRef<AbortController | null>(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 };
}

View File

@@ -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<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 };
}

View File

@@ -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<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);
return (): void => {
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,
};
}

View File

@@ -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<void>;
addLog: (req: AddLogPathRequest) => Promise<void>;
}
/**
* Load the detail view for a single jail config and expose edit actions.
*/
export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
const [jail, setJail] = useState<JailConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await updateJailConfig(name, update);
load();
},
[name, load],
);
const addLog = useCallback(
async (req: AddLogPathRequest): Promise<void> => {
await addLogPath(name, req);
load();
},
[name, load],
);
return { jail, loading, error, refresh: load, updateJail, addLog };
}

View File

@@ -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<void>;
reloadAll: () => Promise<void>;
}
/**
* Load all jail configs and expose update controls.
*/
export function useJailConfigs(): UseJailConfigsResult {
const [jails, setJails] = useState<JailConfig[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await updateJailConfig(name, update);
load();
},
[load],
);
const reloadAll = useCallback(async (): Promise<void> => {
await reloadConfig();
load();
}, [load]);
return { jails, total, loading, error, refresh: load, updateJail, reloadAll };
}

View File

@@ -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<void>;
removeIp: (ip: string) => Promise<void>;
toggleIgnoreSelf: (on: boolean) => Promise<void>;
start: () => Promise<void>;
stop: () => Promise<void>;
reload: () => Promise<void>;
setIdle: (on: boolean) => Promise<void>;
}
/**
* Fetch and manage the detail view for a single jail.
*/
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 = useCallback(async (ip: string): Promise<void> => {
await addIgnoreIp(name, ip);
load();
}, [name, load]);
const removeIp = useCallback(async (ip: string): Promise<void> => {
await delIgnoreIp(name, ip);
load();
}, [name, load]);
const toggleIgnoreSelf = useCallback(async (on: boolean): Promise<void> => {
await toggleIgnoreSelfApi(name, on);
load();
}, [name, load]);
const start = useCallback(async (): Promise<void> => {
await startJail(name);
load();
}, [name, load]);
const stop = useCallback(async (): Promise<void> => {
await stopJail(name);
load();
}, [name, load]);
const reload = useCallback(async (): Promise<void> => {
await reloadJail(name);
load();
}, [name, load]);
const setIdle = useCallback(async (on: boolean): Promise<void> => {
await setJailIdle(name, on);
load();
}, [name, load]);
return {
jail,
ignoreList,
ignoreSelf,
loading,
error,
refresh: load,
addIp,
removeIp,
toggleIgnoreSelf,
start,
stop,
reload,
setIdle,
};
}

View File

@@ -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<void>;
stopJail: (name: string) => Promise<void>;
setIdle: (name: string, on: boolean) => Promise<void>;
reloadJail: (name: string) => Promise<void>;
reloadAll: () => Promise<void>;
}
/**
* Fetch and manage the jail overview list.
*/
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(() => {
load();
}),
reloadJail: withRefresh(reloadJail),
reloadAll: () => reloadAllJails().then(() => {
load();
}),
};
}

View File

@@ -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<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 };
}
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";

View File

@@ -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<void>;
}
/**
* Execute a log preview and expose the response state.
*/
export function useLogPreview(): UseLogPreviewResult {
const [preview, setPreview] = useState<LogPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const run = useCallback(async (req: LogPreviewRequest): Promise<void> => {
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 };
}

View File

@@ -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<void>;
}
/**
* Execute regex tests and expose the current response.
*/
export function useRegexTester(): UseRegexTesterResult {
const [result, setResult] = useState<RegexTestResponse | null>(null);
const [testing, setTesting] = useState(false);
const test = useCallback(async (req: RegexTestRequest): Promise<void> => {
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 };
}

View File

@@ -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<void>;
}
/**
* Trigger and track a manual blocklist import run.
*/
export function useRunImport(): UseRunImportReturn {
const [running, setRunning] = useState(false);
const [lastResult, setLastResult] = useState<ImportRunResult | null>(null);
const [error, setError] = useState<string | null>(null);
const runNow = useCallback(async (): Promise<void> => {
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 };
}

View File

@@ -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<void>;
}
/**
* Fetch and update the blocklist import schedule.
*/
export function useSchedule(): UseScheduleReturn {
const [info, setInfo] = useState<ScheduleInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<void> => {
const updated = await updateSchedule(config);
setInfo(updated);
}, []);
return { info, loading, error, saveSchedule };
}

View File

@@ -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<void>;
flush: () => Promise<string>;
reload: () => Promise<void>;
restart: () => Promise<void>;
}
/**
* Load server settings and provide control actions.
*/
export function useServerSettings(): UseServerSettingsResult {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await updateServerSettings(update);
load();
},
[load],
);
const reload = useCallback(async (): Promise<void> => {
await reloadConfig();
load();
}, [load]);
const restart = useCallback(async (): Promise<void> => {
await restartFail2Ban();
load();
}, [load]);
const flush = useCallback(async (): Promise<string> => {
return flushLogs();
}, []);
return {
settings,
loading,
error,
refresh: load,
updateSettings,
flush,
reload,
restart,
};
}