/** * MapPage — geographical overview of fail2ban bans. * * Shows a clickable SVG world map coloured by ban density, a time-range * selector, and a companion table filtered by the selected country (or all * bans when no country is selected). */ import { useState, useMemo, useEffect } from "react"; import { Button, MessageBar, MessageBarActions, MessageBarBody, Spinner, Text, makeStyles, mergeClasses, tokens, } from "@fluentui/react-components"; import { ArrowCounterclockwiseRegular, DismissRegular, } from "@fluentui/react-icons"; import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { WorldMap } from "../components/WorldMap"; import { useMapData } from "../hooks/useMapData"; import { useMapColorThresholds } from "../hooks/useMapColorThresholds"; import { MapBansTable } from "./map/MapBansTable"; import { getDataSource } from "../utils/queryUtils"; import type { TimeRange } from "../types/map"; import type { BanOriginFilter } from "../types/ban"; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const useStyles = makeStyles({ root: { display: "flex", flexDirection: "column", gap: tokens.spacingVerticalL, padding: tokens.spacingVerticalXXL, paddingLeft: tokens.spacingHorizontalXXL, paddingRight: tokens.spacingHorizontalXXL, }, header: { display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: tokens.spacingHorizontalM, }, tableWrapper: { overflow: "auto", maxHeight: "420px", borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorNeutralStroke1}`, }, stickyHeaderCell: { position: "sticky", top: 0, zIndex: 1, backgroundColor: tokens.colorNeutralBackground1, boxShadow: `0 1px 0 ${tokens.colorNeutralStroke2}`, }, filterBar: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: tokens.spacingHorizontalM, padding: tokens.spacingVerticalS, borderRadius: tokens.borderRadiusMedium, backgroundColor: tokens.colorNeutralBackground2, }, headerActions: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap", }, initialLoad: { display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL, }, summaryRow: { display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS, }, summaryText: { color: tokens.colorNeutralForeground3, }, tableWrapperLoading: { opacity: 0.5, transition: "opacity 150ms", }, pagination: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: tokens.spacingHorizontalS, padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, borderTop: `1px solid ${tokens.colorNeutralStroke2}`, backgroundColor: tokens.colorNeutralBackground2, position: "sticky", bottom: 0, zIndex: 1, }, }); // --------------------------------------------------------------------------- // MapPage // --------------------------------------------------------------------------- export function MapPage(): React.JSX.Element { const styles = useStyles(); 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 source = getDataSource(range); const { countries, countryNames, bans, total, loading, error, refresh } = useMapData(range, originFilter, source, selectedCountry ?? undefined); // True after the first successful data load — keeps the map mounted // during subsequent re-fetches so country selection gives instant feedback. const [hasLoadedOnce, setHasLoadedOnce] = useState(false); useEffect(() => { if (!loading && !error) setHasLoadedOnce(true); }, [loading, error]); const { thresholds: mapThresholds, error: mapThresholdError, } = useMapColorThresholds(); const thresholdLow = mapThresholds?.threshold_low ?? 20; const thresholdMedium = mapThresholds?.threshold_medium ?? 50; const thresholdHigh = mapThresholds?.threshold_high ?? 100; const [dismissedThresholdWarning, setDismissedThresholdWarning] = useState(false); useEffect(() => { setPage(1); }, [range, originFilter, selectedCountry, pageSize]); /** Bans visible in the companion table (filtered by selected country). */ const visibleBans = useMemo(() => { if (!selectedCountry) return bans; return bans.filter((b) => b.country_code === selectedCountry); }, [bans, selectedCountry]); const selectedCountryName = selectedCountry ? (countryNames[selectedCountry] ?? selectedCountry) : null; const totalPages = Math.max(1, Math.ceil(visibleBans.length / pageSize)); // Clamp page to totalPages when data shrinks below current page offset useEffect(() => { if (page > totalPages) { setPage(totalPages); } }, [totalPages, page]); 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 (
{/* ---------------------------------------------------------------- */} {/* Header row */} {/* ---------------------------------------------------------------- */}
World Map
{ setRange(value); setSelectedCountry(null); }} originFilter={originFilter} onOriginFilterChange={(value) => { setOriginFilter(value); setSelectedCountry(null); }} />
{/* ---------------------------------------------------------------- */} {/* Error / loading states */} {/* ---------------------------------------------------------------- */} {error && ( {error} )} {mapThresholdError && !dismissedThresholdWarning && ( Map color thresholds could not be loaded. Using default thresholds.
)} {/* ---------------------------------------------------------------- */} {/* Summary line */} {/* ---------------------------------------------------------------- */} {!error && hasLoadedOnce && (
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period {" · "} {String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected {loading && }
)} {/* ---------------------------------------------------------------- */} {/* Companion bans table */} {/* ---------------------------------------------------------------- */} {!error && hasLoadedOnce && (
)} ); }