303 lines
8.1 KiB
TypeScript
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 };
|
|
}
|