Stage 9: ban history — backend service, router, frontend history page
- history.py models: HistoryBanItem, HistoryListResponse, IpTimelineEvent, IpDetailResponse
- history_service.py: list_history() with dynamic WHERE clauses (range/jail/ip
prefix/all-time), get_ip_detail() with timeline aggregation
- history.py router: GET /api/history + GET /api/history/{ip} (404 for unknown)
- Fixed latent bug in ban_service._parse_data_json: json.loads('null') -> None
-> AttributeError; now checks isinstance(parsed, dict) before assigning obj
- 317 tests pass (27 new), ruff + mypy clean (46 files)
- types/history.ts, api/history.ts, hooks/useHistory.ts created
- HistoryPage.tsx: filter bar (time range/jail/IP), DataGrid table,
high-ban-count row highlighting, per-IP IpDetailView with timeline,
pagination
- Frontend tsc + ESLint clean (0 errors/warnings)
- Tasks.md Stage 9 marked done
This commit is contained in:
111
frontend/src/hooks/useHistory.ts
Normal file
111
frontend/src/hooks/useHistory.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* `useHistory` hook — fetches and manages ban history data.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchHistory, fetchIpHistory } from "../api/history";
|
||||
import type {
|
||||
HistoryBanItem,
|
||||
HistoryQuery,
|
||||
IpDetailResponse,
|
||||
} from "../types/history";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHistory — paginated list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseHistoryResult {
|
||||
items: HistoryBanItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setPage: (page: number) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useHistory(query: HistoryQuery = {}): UseHistoryResult {
|
||||
const [items, setItems] = useState<HistoryBanItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(query.page ?? 1);
|
||||
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();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchHistory({ ...query, page })
|
||||
.then((resp) => {
|
||||
setItems(resp.items);
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [query, page]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { items, total, page, loading, error, setPage, refresh: load };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useIpHistory — per-IP detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseIpHistoryResult {
|
||||
detail: IpDetailResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useIpHistory(ip: string): UseIpHistoryResult {
|
||||
const [detail, setDetail] = useState<IpDetailResponse | 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();
|
||||
abortRef.current = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchIpHistory(ip)
|
||||
.then((resp) => {
|
||||
setDetail(resp);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.name !== "AbortError") {
|
||||
setError(err.message);
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [ip]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { detail, loading, error, refresh: load };
|
||||
}
|
||||
Reference in New Issue
Block a user