Files
BanGUI/frontend/src/pages/MapPage.tsx
2026-05-04 13:13:01 +02:00

329 lines
11 KiB
TypeScript

/**
* 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).
*
* Critical sections wrapped with SectionErrorBoundary for resilience.
*/
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 { SectionErrorBoundary } from "../components/SectionErrorBoundary";
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<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(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 (
<div className={styles.root} data-testid="map-page">
{/* ---------------------------------------------------------------- */}
{/* Header row */}
{/* ---------------------------------------------------------------- */}
<div className={styles.header}>
<Text as="h1" size={700} weight="semibold">
World Map
</Text>
<div className={styles.headerActions}>
<DashboardFilterBar
timeRange={range}
onTimeRangeChange={(value) => {
setRange(value);
setSelectedCountry(null);
}}
originFilter={originFilter}
onOriginFilterChange={(value) => {
setOriginFilter(value);
setSelectedCountry(null);
}}
/>
<Button
icon={<ArrowCounterclockwiseRegular />}
onClick={(): void => {
refresh();
}}
disabled={loading}
title="Refresh"
/>
</div>
</div>
{/* ---------------------------------------------------------------- */}
{/* Error / loading states */}
{/* ---------------------------------------------------------------- */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{mapThresholdError && !dismissedThresholdWarning && (
<MessageBar intent="warning">
<MessageBarBody>
Map color thresholds could not be loaded. Using default thresholds.
</MessageBarBody>
<MessageBarActions>
<Button
appearance="transparent"
size="small"
icon={<DismissRegular />}
onClick={(): void => {
setDismissedThresholdWarning(true);
}}
/>
</MessageBarActions>
</MessageBar>
)}
{/* Initial load spinner — only shown before the first data arrives. */}
{loading && !error && !hasLoadedOnce && (
<div className={styles.initialLoad}>
<Spinner label="Loading map data…" />
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* World map */}
{/* Keep the map mounted after first load so clicking a country gives */}
{/* immediate visual feedback before the filtered data arrives. */}
{/* ---------------------------------------------------------------- */}
{!error && hasLoadedOnce && (
<SectionErrorBoundary sectionName="World Map">
<WorldMap
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/>
</SectionErrorBoundary>
)}
{/* ---------------------------------------------------------------- */}
{/* Active country filter info bar */}
{/* ---------------------------------------------------------------- */}
{selectedCountry && (
<div className={styles.filterBar}>
<Text size={300}>
Showing <strong>{String(visibleBans.length)}</strong> bans from{" "}
<strong>{selectedCountryName ?? selectedCountry}</strong>
{" "}({String(countries[selectedCountry] ?? 0)} total in window)
</Text>
<Button
appearance="subtle"
size="small"
icon={<DismissRegular />}
onClick={(): void => {
setSelectedCountry(null);
}}
>
Clear filter
</Button>
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* Summary line */}
{/* ---------------------------------------------------------------- */}
{!error && hasLoadedOnce && (
<div className={styles.summaryRow}>
<Text size={300} className={styles.summaryText}>
{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
</Text>
{loading && <Spinner size="tiny" />}
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* Companion bans table */}
{/* ---------------------------------------------------------------- */}
{!error && hasLoadedOnce && (
<SectionErrorBoundary sectionName="Map Ban Table">
<div className={mergeClasses(styles.tableWrapper, loading && styles.tableWrapperLoading)}>
<MapBansTable
pageBans={pageBans}
visibleCount={visibleBans.length}
page={page}
pageSize={pageSize}
totalPages={totalPages}
hasPrev={hasPrev}
hasNext={hasNext}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</div>
</SectionErrorBoundary>
)}
</div>
);
}