diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 037ee84..e17f099 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -8,16 +8,26 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. ## Open Issues -- **World Map highlight does not reset on mouse leave** (done) - - Location: `frontend/src/components/WorldMap.tsx`, `GeoLayer` component - - Root cause: interactive event handlers (`onMouseEnter`, `onMouseMove`, `onMouseLeave`, `onClick`, `onKeyDown`) were on wrapping `` element, creating mismatch with `Geography` internal hover state. - - Fix applied: - 1. Interactive props and aria attributes are on ``. - 2. Hover highlight uses `style.hover` with derived `fill` from `isSelected/count`. - 3. Tooltip state resets via `Geography.onMouseLeave` -> `setTooltip(null)`. - 4. Test asserts tooltip appears/disappears and button role label is correct. - - Verification commands: - - `cd frontend && npx vitest --run src/components/__tests__/WorldMap.test.tsx` - - `cd frontend && npx vitest --run` +### Worldmap list pagination (new) +- Status: done +- Goal: Add paging controls to the WorldMap companion bans table (map page) so it matches Dashboard ban-list behavior and supports country drilldown without rendering / scrolling huge result sets. + +- Implement in `frontend/src/pages/MapPage.tsx` using the dashboard pattern from `frontend/src/components/BanTable.tsx`: + 1. Add local state: `page` (start 1), `pageSize` (e.g., 100). + 2. Derive `visibleBans` from `useMemo` + `selectedCountry`, then compute `pageBans` using `slice((page-1)*pageSize, page*pageSize)`. + 3. Compute `totalPages = Math.max(1, Math.ceil(visibleBans.length / pageSize))`, `hasPrev`, `hasNext`. + 4. Render a footer row below Table with total, page, prev/next buttons (use icons `ChevronLeftRegular`, `ChevronRightRegular`, or existing style). + 5. Wire buttons to `setPage(page - 1)` / `setPage(page + 1)`, disabled by bounds. + 6. Reset `page` to 1 whenever `visibleBans` filter changes: on `range`, `originFilter`, `selectedCountry`, or when data refreshes. + +- UX details: + - Keep existing “no bans found” row behavior. + - Keep summary info bar and total line consistent; show current slice counts in message as needed. + - Ensure paged data is used for `TableBody` mapping. + +- Optional follow-ups: + - Allow page-size selector if needed (25/50/100), updating `totalPages` and reset to page 1 on change. + - Add tests in `frontend/src/pages/__tests__/MapPage.test.tsx` for paging behavior (initial page, next/prev, filtering reset). + - Add documentation in `Docs/Web-Design.md` if there is a worldmap data table UI guideline. diff --git a/Docs/Web-Design.md b/Docs/Web-Design.md index c04b3d1..2488bbd 100644 --- a/Docs/Web-Design.md +++ b/Docs/Web-Design.md @@ -210,7 +210,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho | Element | Fluent component | Notes | |---|---|---| -| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. | +| Data tables | `DetailsList` | All ban tables, jail overviews, history tables. Enable column sorting, selection, and shimmer loading. Use clear pagination controls (page number + prev/next) and a page-size selector (25/50/100) for large result sets. | | Stat cards | `DocumentCard` or custom `Stack` card | Dashboard status bar — server status, total bans, active jails. Use `Depth 4`. | | Status indicators | `Badge` / `Icon` + colour | Server online/offline, jail running/stopped/idle. | | Country labels | Monospaced text + flag emoji or icon | Geo data next to IP addresses. | diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index 06d89d1..1b3fbdc 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -25,7 +25,12 @@ import { makeStyles, tokens, } from "@fluentui/react-components"; -import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons"; +import { + ArrowCounterclockwiseRegular, + ChevronLeftRegular, + ChevronRightRegular, + DismissRegular, +} from "@fluentui/react-icons"; import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { WorldMap } from "../components/WorldMap"; import { useMapData } from "../hooks/useMapData"; @@ -68,6 +73,15 @@ const useStyles = makeStyles({ borderRadius: tokens.borderRadiusMedium, backgroundColor: tokens.colorNeutralBackground2, }, + pagination: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: tokens.spacingHorizontalS, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground2, + }, }); // --------------------------------------------------------------------------- @@ -79,6 +93,10 @@ export function MapPage(): React.JSX.Element { const [range, setRange] = useState("24h"); const [originFilter, setOriginFilter] = useState("all"); const [selectedCountry, setSelectedCountry] = useState(null); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(100); + + const PAGE_SIZE_OPTIONS = [25, 50, 100] as const; const { countries, countryNames, bans, total, loading, error, refresh } = useMapData(range, originFilter); @@ -99,6 +117,10 @@ export function MapPage(): React.JSX.Element { } }, [mapThresholdError]); + useEffect(() => { + setPage(1); + }, [range, originFilter, selectedCountry, bans, pageSize]); + /** Bans visible in the companion table (filtered by selected country). */ const visibleBans = useMemo(() => { if (!selectedCountry) return bans; @@ -109,6 +131,15 @@ export function MapPage(): React.JSX.Element { ? (countryNames[selectedCountry] ?? selectedCountry) : null; + const totalPages = Math.max(1, Math.ceil(visibleBans.length / pageSize)); + const hasPrev = page > 1; + const hasNext = page < totalPages; + + const pageBans = useMemo(() => { + const start = (page - 1) * pageSize; + return visibleBans.slice(start, start + pageSize); + }, [visibleBans, page, pageSize]); + return (
{/* ---------------------------------------------------------------- */} @@ -235,7 +266,7 @@ export function MapPage(): React.JSX.Element { ) : ( - visibleBans.map((ban) => ( + pageBans.map((ban) => ( {ban.ip} @@ -282,6 +313,53 @@ export function MapPage(): React.JSX.Element { )} +
+
+ + Showing {pageBans.length} of {visibleBans.length} filtered ban{visibleBans.length !== 1 ? "s" : ""} + {" · "}Page {page} of {totalPages} + + +
+ + Page size + + +
+
+
+
+
)} diff --git a/frontend/src/pages/__tests__/MapPage.test.tsx b/frontend/src/pages/__tests__/MapPage.test.tsx index fcfe3f0..7fff589 100644 --- a/frontend/src/pages/__tests__/MapPage.test.tsx +++ b/frontend/src/pages/__tests__/MapPage.test.tsx @@ -2,42 +2,43 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { getLastArgs, setMapData } from "../../hooks/useMapData"; import { MapPage } from "../MapPage"; -const mockFetchMapColorThresholds = vi.fn(async () => ({ - threshold_low: 10, - threshold_medium: 50, - threshold_high: 100, -})); - -let lastArgs: { range: string; origin: string } = { range: "", origin: "" }; -const mockUseMapData = vi.fn((range: string, origin: string) => { - lastArgs = { range, origin }; - return { +vi.mock("../../hooks/useMapData", () => { + let lastArgs: { range: string; origin: string } = { range: "", origin: "" }; + let dataState = { countries: {}, countryNames: {}, bans: [], total: 0, loading: false, error: null, - refresh: vi.fn(), + refresh: () => {}, + }; + + return { + useMapData: (range: string, origin: string) => { + lastArgs = { range, origin }; + return { ...dataState }; + }, + setMapData: (newState: Partial) => { + dataState = { ...dataState, ...newState }; + }, + getLastArgs: () => lastArgs, }; }); -vi.mock("../hooks/useMapData", () => ({ - useMapData: (range: string, origin: string) => mockUseMapData(range, origin), +vi.mock("../../api/config", () => ({ + fetchMapColorThresholds: vi.fn(async () => ({ + threshold_low: 10, + threshold_medium: 50, + threshold_high: 100, + })), })); -vi.mock("../api/config", async () => ({ - fetchMapColorThresholds: mockFetchMapColorThresholds, -})); - -const mockWorldMap = vi.fn((_props: unknown) =>
); -vi.mock("../components/WorldMap", () => ({ - WorldMap: (props: unknown) => { - mockWorldMap(props); - return
; - }, +vi.mock("../../components/WorldMap", () => ({ + WorldMap: () =>
, })); describe("MapPage", () => { @@ -51,17 +52,63 @@ describe("MapPage", () => { ); // Initial load should call useMapData with default filters. - expect(lastArgs).toEqual({ range: "24h", origin: "all" }); - - // Map should receive country names from the hook so tooltips can show human-readable labels. - expect(mockWorldMap).toHaveBeenCalled(); - const firstCallArgs = mockWorldMap.mock.calls[0]?.[0]; - expect(firstCallArgs).toMatchObject({ countryNames: {} }); + expect(getLastArgs()).toEqual({ range: "24h", origin: "all" }); await user.click(screen.getByRole("button", { name: /Last 7 days/i })); - expect(lastArgs.range).toBe("7d"); + expect(getLastArgs().range).toBe("7d"); await user.click(screen.getByRole("button", { name: /Blocklist/i })); - expect(lastArgs.origin).toBe("blocklist"); + expect(getLastArgs().origin).toBe("blocklist"); + }); + + it("supports pagination with 100 items per page and reset on filter changes", async () => { + const user = userEvent.setup(); + + const bans = Array.from({ length: 120 }, (_, index) => ({ + ip: `192.0.2.${index}`, + jail: "ssh", + banned_at: new Date(Date.now() - index * 1000).toISOString(), + service: null, + country_code: "US", + country_name: "United States", + asn: null, + org: null, + ban_count: 1, + origin: "selfblock", + })); + + setMapData({ + countries: { US: 120 }, + countryNames: { US: "United States" }, + bans, + total: 120, + loading: false, + error: null, + }); + + render( + + + , + ); + + expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument(); + expect(screen.getByText(/Showing 100 of 120 filtered bans/i)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Next page/i })); + expect(await screen.findByText(/Page 2 of 2/i)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Previous page/i })); + expect(await screen.findByText(/Page 1 of 2/i)).toBeInTheDocument(); + + // Page size selector should adjust pagination + await user.selectOptions(screen.getByRole("combobox", { name: /Page size/i }), "25"); + expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument(); + expect(screen.getByText(/Showing 25 of 120 filtered bans/i)).toBeInTheDocument(); + + // Changing filter keeps page reset to 1 + await user.click(screen.getByRole("button", { name: /Blocklist/i })); + expect(getLastArgs().origin).toBe("blocklist"); + expect(await screen.findByText(/Page 1 of 5/i)).toBeInTheDocument(); }); });