329 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|