Files
BanGUI/frontend/src/pages/MapPage.tsx

290 lines
10 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).
*/
import { useState, useMemo, useEffect } from "react";
import {
Badge,
Button,
MessageBar,
MessageBarBody,
Spinner,
Table,
TableBody,
TableCell,
TableCellLayout,
TableHeader,
TableHeaderCell,
TableRow,
Text,
Tooltip,
makeStyles,
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 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}`,
},
filterBar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
padding: tokens.spacingVerticalS,
borderRadius: tokens.borderRadiusMedium,
backgroundColor: tokens.colorNeutralBackground2,
},
});
// ---------------------------------------------------------------------------
// 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 { countries, countryNames, bans, total, loading, error, refresh } =
useMapData(range, originFilter);
const {
thresholds: mapThresholds,
error: mapThresholdError,
} = useMapColorThresholds();
const thresholdLow = mapThresholds?.threshold_low ?? 20;
const thresholdMedium = mapThresholds?.threshold_medium ?? 50;
const thresholdHigh = mapThresholds?.threshold_high ?? 100;
useEffect(() => {
if (mapThresholdError) {
// Silently fall back to defaults if fetch fails
console.warn("Failed to load map color thresholds:", mapThresholdError);
}
}, [mapThresholdError]);
/** 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;
return (
<div className={styles.root}>
{/* ---------------------------------------------------------------- */}
{/* Header row */}
{/* ---------------------------------------------------------------- */}
<div className={styles.header}>
<Text as="h1" size={700} weight="semibold">
World Map
</Text>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
<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>
)}
{loading && !error && (
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
<Spinner label="Loading map data…" />
</div>
)}
{/* ---------------------------------------------------------------- */}
{/* World map */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<WorldMap
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={setSelectedCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/>
)}
{/* ---------------------------------------------------------------- */}
{/* 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 */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
{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>
)}
{/* ---------------------------------------------------------------- */}
{/* Companion bans table */}
{/* ---------------------------------------------------------------- */}
{!loading && !error && (
<div className={styles.tableWrapper}>
<Table size="small" aria-label="Bans list">
<TableHeader>
<TableRow>
<TableHeaderCell>IP Address</TableHeaderCell>
<TableHeaderCell>Jail</TableHeaderCell>
<TableHeaderCell>Banned At</TableHeaderCell>
<TableHeaderCell>Country</TableHeaderCell>
<TableHeaderCell>Origin</TableHeaderCell>
<TableHeaderCell>Times Banned</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{visibleBans.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>
<TableCellLayout>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
No bans found.
</Text>
</TableCellLayout>
</TableCell>
</TableRow>
) : (
visibleBans.map((ban) => (
<TableRow key={`${ban.ip}-${ban.banned_at}`}>
<TableCell>
<TableCellLayout>{ban.ip}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{ban.jail}</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
{new Date(ban.banned_at).toLocaleString()}
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
{ban.country_name ?? ban.country_code ? (
ban.country_name ?? ban.country_code
) : (
<Tooltip
content="Country could not be resolved — will retry automatically."
relationship="description"
>
<Text style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
</Tooltip>
)}
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
<Badge
appearance="tint"
color={ban.origin === "blocklist" ? "brand" : "informative"}
>
{ban.origin === "blocklist" ? "Blocklist" : "Selfblock"}
</Badge>
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>{String(ban.ban_count)}</TableCellLayout>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
);
}