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

303 lines
8.1 KiB
TypeScript

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