- Add persistent geo_cache SQLite table (db.py) - Rewrite geo_service: batch API (100 IPs/call), two-tier cache, no caching of failed lookups so they are retried - Pre-warm geo cache from DB on startup (main.py lifespan) - Rewrite bans_by_country: SQL GROUP BY ip aggregation + lookup_batch instead of 2000-row fetch + asyncio.gather individual calls - Pre-warm geo cache after blocklist import (blocklist_service) - Add 300ms debounce to useMapData hook to cancel stale requests - Add perf benchmark asserting <2s for 10k bans - Add seed_10k_bans.py script for manual perf testing
101 lines
3.1 KiB
TypeScript
101 lines
3.1 KiB
TypeScript
/**
|
|
* `useMapData` hook — fetches and manages ban-by-country data.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { fetchBansByCountry } from "../api/map";
|
|
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
|
|
import type { BanOriginFilter } from "../types/ban";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Debounce delay in milliseconds before firing a fetch after filter changes. */
|
|
const DEBOUNCE_MS = 300;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Return type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface UseMapDataResult {
|
|
/** Per-country ban counts (ISO alpha-2 → count). */
|
|
countries: Record<string, number>;
|
|
/** ISO alpha-2 → country name mapping. */
|
|
countryNames: Record<string, string>;
|
|
/** All ban records in the selected window. */
|
|
bans: MapBanItem[];
|
|
/** Total ban count. */
|
|
total: number;
|
|
/** True while a fetch is in flight. */
|
|
loading: boolean;
|
|
/** Error message or null. */
|
|
error: string | null;
|
|
/** Trigger a manual re-fetch. */
|
|
refresh: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useMapData(
|
|
range: TimeRange = "24h",
|
|
origin: BanOriginFilter = "all",
|
|
): UseMapDataResult {
|
|
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const load = useCallback((): void => {
|
|
// Cancel any pending debounce timer.
|
|
if (debounceRef.current != null) {
|
|
clearTimeout(debounceRef.current);
|
|
}
|
|
// Show loading immediately so the skeleton / spinner appears.
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
debounceRef.current = setTimeout((): void => {
|
|
// Abort any in-flight request from a previous filter selection.
|
|
abortRef.current?.abort();
|
|
abortRef.current = new AbortController();
|
|
|
|
fetchBansByCountry(range, origin)
|
|
.then((resp) => {
|
|
setData(resp);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (err instanceof Error && err.name !== "AbortError") {
|
|
setError(err.message);
|
|
}
|
|
})
|
|
.finally((): void => {
|
|
setLoading(false);
|
|
});
|
|
}, DEBOUNCE_MS);
|
|
}, [range, origin]);
|
|
|
|
useEffect((): (() => void) => {
|
|
load();
|
|
return (): void => {
|
|
if (debounceRef.current != null) {
|
|
clearTimeout(debounceRef.current);
|
|
}
|
|
abortRef.current?.abort();
|
|
};
|
|
}, [load]);
|
|
|
|
return {
|
|
countries: data?.countries ?? {},
|
|
countryNames: data?.country_names ?? {},
|
|
bans: data?.bans ?? [],
|
|
total: data?.total ?? 0,
|
|
loading,
|
|
error,
|
|
refresh: load,
|
|
};
|
|
}
|