Refactor frontend API calls into hooks and complete task states

This commit is contained in:
2026-03-20 15:18:04 +01:00
parent d30d138146
commit 28a7610276
16 changed files with 483 additions and 409 deletions

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useJailBannedIps } from "../useJails";
import * as api from "../../api/jails";
vi.mock("../../api/jails");
describe("useJailBannedIps", () => {
it("loads bans and allows unban", async () => {
const fetchMock = vi.mocked(api.fetchJailBannedIps);
const unbanMock = vi.mocked(api.unbanIp);
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25 });
unbanMock.mockResolvedValue({ message: "ok", jail: "sshd" });
const { result } = renderHook(() => useJailBannedIps("sshd"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.items.length).toBe(1);
await act(async () => {
await result.current.unban("1.2.3.4");
});
expect(unbanMock).toHaveBeenCalledWith("1.2.3.4", "sshd");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useMapColorThresholds } from "../useMapColorThresholds";
import * as api from "../../api/config";
vi.mock("../../api/config");
describe("useMapColorThresholds", () => {
it("loads thresholds and exposes values", async () => {
const mocked = vi.mocked(api.fetchMapColorThresholds);
mocked.mockResolvedValue({ threshold_low: 10, threshold_medium: 20, threshold_high: 50 });
const { result } = renderHook(() => useMapColorThresholds());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.thresholds).toEqual({ threshold_low: 10, threshold_medium: 20, threshold_high: 50 });
expect(result.current.error).toBeNull();
});
it("updates thresholds via callback", async () => {
const fetchMock = vi.mocked(api.fetchMapColorThresholds);
const updateMock = vi.mocked(api.updateMapColorThresholds);
fetchMock.mockResolvedValue({ threshold_low: 10, threshold_medium: 20, threshold_high: 50 });
updateMock.mockResolvedValue({ threshold_low: 15, threshold_medium: 25, threshold_high: 75 });
const { result } = renderHook(() => useMapColorThresholds());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.updateThresholds({ threshold_low: 15, threshold_medium: 25, threshold_high: 75 });
});
expect(result.current.thresholds).toEqual({ threshold_low: 15, threshold_medium: 25, threshold_high: 75 });
});
});

View File

@@ -9,6 +9,7 @@ import {
fetchBlocklists,
fetchImportLog,
fetchSchedule,
previewBlocklist,
runImportNow,
updateBlocklist,
updateSchedule,
@@ -35,6 +36,7 @@ export interface UseBlocklistsReturn {
createSource: (payload: BlocklistSourceCreate) => Promise<BlocklistSource>;
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;
removeSource: (id: number) => Promise<void>;
previewSource: (id: number) => Promise<PreviewResponse>;
}
/**
@@ -99,7 +101,20 @@ export function useBlocklists(): UseBlocklistsReturn {
setSources((prev) => prev.filter((s) => s.id !== id));
}, []);
return { sources, loading, error, refresh: load, createSource, updateSource, removeSource };
const previewSource = useCallback(async (id: number): Promise<PreviewResponse> => {
return previewBlocklist(id);
}, []);
return {
sources,
loading,
error,
refresh: load,
createSource,
updateSource,
removeSource,
previewSource,
};
}
// ---------------------------------------------------------------------------

View File

@@ -13,6 +13,7 @@ import {
delIgnoreIp,
fetchActiveBans,
fetchJail,
fetchJailBannedIps,
fetchJails,
lookupIp,
reloadAllJails,
@@ -261,6 +262,107 @@ export function useJailDetail(name: string): UseJailDetailResult {
};
}
// ---------------------------------------------------------------------------
// 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) {
setError(err instanceof Error ? err.message : String(err));
} 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
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,55 @@
import { useCallback, useEffect, useState } from "react";
import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config";
import type {
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
} from "../types/config";
export interface UseMapColorThresholdsResult {
thresholds: MapColorThresholdsResponse | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
updateThresholds: (payload: MapColorThresholdsUpdate) => Promise<MapColorThresholdsResponse>;
}
export function useMapColorThresholds(): UseMapColorThresholdsResult {
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const data = await fetchMapColorThresholds();
setThresholds(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch map color thresholds");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const updateThresholds = useCallback(
async (payload: MapColorThresholdsUpdate): Promise<MapColorThresholdsResponse> => {
const updated = await updateMapColorThresholds(payload);
setThresholds(updated);
return updated;
},
[],
);
return {
thresholds,
loading,
error,
refresh: load,
updateThresholds,
};
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { fetchTimezone } from "../api/setup";
export interface UseTimezoneDataResult {
timezone: string;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
export function useTimezoneData(): UseTimezoneDataResult {
const [timezone, setTimezone] = useState<string>("UTC");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const resp = await fetchTimezone();
setTimezone(resp.timezone);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch timezone");
setTimezone("UTC");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return {
timezone,
loading,
error,
refresh: load,
};
}