290 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|