Add origin field and filter for ban sources (Tasks 1 & 2)
- Task 1: Mark imported blocklist IP addresses
- Add BanOrigin type and _derive_origin() to ban.py model
- Populate origin field in ban_service list_bans() and bans_by_country()
- BanTable and MapPage companion table show origin badge column
- Tests: origin derivation in test_ban_service.py and test_dashboard.py
- Task 2: Add origin filter to dashboard and world map
- ban_service: _origin_sql_filter() helper; origin param on list_bans()
and bans_by_country()
- dashboard router: optional origin query param forwarded to service
- Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in ban.ts
- fetchBans / fetchBansByCountry forward origin to API
- useBans / useMapData accept and pass origin; page resets on change
- BanTable accepts origin prop; DashboardPage adds segmented filter
- MapPage adds origin Select next to time-range picker
- Tests: origin filter assertions in test_ban_service and test_dashboard
This commit is contained in:
@@ -13,6 +13,8 @@ import type {
|
||||
JailConfigUpdate,
|
||||
LogPreviewRequest,
|
||||
LogPreviewResponse,
|
||||
MapColorThresholdsResponse,
|
||||
MapColorThresholdsUpdate,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
ServerSettingsResponse,
|
||||
@@ -119,3 +121,21 @@ export async function flushLogs(
|
||||
);
|
||||
return resp.message;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map color thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchMapColorThresholds(
|
||||
): Promise<MapColorThresholdsResponse> {
|
||||
return get<MapColorThresholdsResponse>(ENDPOINTS.configMapColorThresholds);
|
||||
}
|
||||
|
||||
export async function updateMapColorThresholds(
|
||||
update: MapColorThresholdsUpdate
|
||||
): Promise<MapColorThresholdsResponse> {
|
||||
return put<MapColorThresholdsResponse>(
|
||||
ENDPOINTS.configMapColorThresholds,
|
||||
update,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { get } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type { DashboardBanListResponse, TimeRange } from "../types/ban";
|
||||
import type { DashboardBanListResponse, TimeRange, BanOriginFilter } from "../types/ban";
|
||||
import type { ServerStatusResponse } from "../types/server";
|
||||
|
||||
/**
|
||||
@@ -26,6 +26,8 @@ export async function fetchServerStatus(): Promise<ServerStatusResponse> {
|
||||
* @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
||||
* @param page - 1-based page number (default `1`).
|
||||
* @param pageSize - Items per page (default `100`).
|
||||
* @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"`
|
||||
* (default `"all"`, which omits the parameter entirely).
|
||||
* @returns Paginated {@link DashboardBanListResponse}.
|
||||
* @throws {ApiError} When the server returns a non-2xx status.
|
||||
*/
|
||||
@@ -33,12 +35,16 @@ export async function fetchBans(
|
||||
range: TimeRange,
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
origin: BanOriginFilter = "all",
|
||||
): Promise<DashboardBanListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
range,
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
});
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export const ENDPOINTS = {
|
||||
configReload: "/config/reload",
|
||||
configRegexTest: "/config/regex-test",
|
||||
configPreviewLog: "/config/preview-log",
|
||||
configMapColorThresholds: "/config/map-color-thresholds",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Server settings
|
||||
|
||||
@@ -5,15 +5,22 @@
|
||||
import { get } from "./client";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import type { BansByCountryResponse, TimeRange } from "../types/map";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
|
||||
/**
|
||||
* Fetch ban counts aggregated by country for the given time window.
|
||||
*
|
||||
* @param range - Time-range preset.
|
||||
* @param range - Time-range preset.
|
||||
* @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"`
|
||||
* (default `"all"`, which omits the parameter entirely).
|
||||
*/
|
||||
export async function fetchBansByCountry(
|
||||
range: TimeRange = "24h",
|
||||
origin: BanOriginFilter = "all",
|
||||
): Promise<BansByCountryResponse> {
|
||||
const url = `${ENDPOINTS.dashboardBansByCountry}?range=${encodeURIComponent(range)}`;
|
||||
return get<BansByCountryResponse>(url);
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
|
||||
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
||||
import { useBans } from "../hooks/useBans";
|
||||
import type { DashboardBanItem, TimeRange } from "../types/ban";
|
||||
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -40,6 +40,11 @@ interface BanTableProps {
|
||||
* Changing this value triggers a re-fetch.
|
||||
*/
|
||||
timeRange: TimeRange;
|
||||
/**
|
||||
* Active origin filter — controlled by the parent `DashboardPage`.
|
||||
* Changing this value triggers a re-fetch and resets to page 1.
|
||||
*/
|
||||
origin?: BanOriginFilter;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -159,6 +164,18 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
|
||||
}),
|
||||
createTableColumn<DashboardBanItem>({
|
||||
columnId: "origin",
|
||||
renderHeaderCell: () => "Origin",
|
||||
renderCell: (item) => (
|
||||
<Badge
|
||||
appearance="tint"
|
||||
color={item.origin === "blocklist" ? "brand" : "informative"}
|
||||
>
|
||||
{item.origin === "blocklist" ? "Blocklist" : "Selfblock"}
|
||||
</Badge>
|
||||
),
|
||||
}),
|
||||
createTableColumn<DashboardBanItem>({
|
||||
columnId: "ban_count",
|
||||
renderHeaderCell: () => "Bans",
|
||||
@@ -183,10 +200,11 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
|
||||
* Data table for the dashboard ban-list view.
|
||||
*
|
||||
* @param props.timeRange - Active time-range preset from the parent page.
|
||||
* @param props.origin - Active origin filter from the parent page.
|
||||
*/
|
||||
export function BanTable({ timeRange }: BanTableProps): React.JSX.Element {
|
||||
export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange);
|
||||
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin);
|
||||
|
||||
const banColumns = buildBanColumns(styles);
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
* country filters the companion table.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { ComposableMap, Geography, useGeographies } from "react-simple-maps";
|
||||
import { makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import type { GeoPermissibleObjects } from "d3-geo";
|
||||
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
||||
import { getBanCountColor } from "../utils/mapColors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
||||
@@ -27,6 +28,7 @@ const GEO_URL =
|
||||
const useStyles = makeStyles({
|
||||
mapWrapper: {
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
@@ -39,39 +41,37 @@ const useStyles = makeStyles({
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
},
|
||||
zoomControls: {
|
||||
position: "absolute",
|
||||
top: tokens.spacingVerticalM,
|
||||
right: tokens.spacingHorizontalM,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXS,
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colour utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Map a ban count to a fill colour intensity. */
|
||||
function getFill(count: number, maxCount: number): string {
|
||||
if (count === 0 || maxCount === 0) return "#E8E8E8";
|
||||
const intensity = count / maxCount;
|
||||
// Interpolate from light amber to deep red
|
||||
const r = Math.round(220 + (220 - 220) * intensity);
|
||||
const g = Math.round(200 - 180 * intensity);
|
||||
const b = Math.round(160 - 160 * intensity);
|
||||
return `rgb(${String(r)},${String(g)},${String(b)})`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
maxCount: number;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
thresholdLow: number;
|
||||
thresholdMedium: number;
|
||||
thresholdHigh: number;
|
||||
}
|
||||
|
||||
function GeoLayer({
|
||||
countries,
|
||||
maxCount,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow,
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
}: GeoLayerProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||
@@ -83,11 +83,12 @@ function GeoLayer({
|
||||
[selectedCountry, onSelectCountry],
|
||||
);
|
||||
|
||||
// react-simple-maps types declare `path` as always defined, but it is
|
||||
// undefined during early renders before the MapProvider context initialises.
|
||||
// Cast through unknown to reflect the true runtime type and guard safely.
|
||||
if (geographies.length === 0) return <></>;
|
||||
|
||||
// react-simple-maps types declare path as always defined, but it can be null
|
||||
// during initial render before MapProvider context initializes. Cast to reflect
|
||||
// the true runtime type and allow safe null checking.
|
||||
const safePath = path as unknown as typeof path | null;
|
||||
if (safePath == null) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -97,12 +98,22 @@ function GeoLayer({
|
||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const centroid = path.centroid(geo as unknown as GeoPermissibleObjects);
|
||||
const [cx, cy] = centroid;
|
||||
|
||||
const fill = isSelected
|
||||
? tokens.colorBrandBackground
|
||||
: getFill(count, maxCount);
|
||||
|
||||
// Compute the fill color based on ban count
|
||||
const fillColor = getBanCountColor(
|
||||
count,
|
||||
thresholdLow,
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
);
|
||||
|
||||
// Only calculate centroid if path is available
|
||||
let cx: number | undefined;
|
||||
let cy: number | undefined;
|
||||
if (safePath != null) {
|
||||
const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects);
|
||||
[cx, cy] = centroid;
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
@@ -130,26 +141,30 @@ function GeoLayer({
|
||||
geography={geo}
|
||||
style={{
|
||||
default: {
|
||||
fill,
|
||||
stroke: tokens.colorNeutralBackground1,
|
||||
strokeWidth: 0.5,
|
||||
fill: isSelected ? tokens.colorBrandBackground : fillColor,
|
||||
stroke: tokens.colorNeutralStroke2,
|
||||
strokeWidth: 0.75,
|
||||
outline: "none",
|
||||
},
|
||||
hover: {
|
||||
fill: cc ? tokens.colorBrandBackgroundHover : fill,
|
||||
stroke: tokens.colorNeutralBackground1,
|
||||
strokeWidth: 0.5,
|
||||
fill: isSelected
|
||||
? tokens.colorBrandBackgroundHover
|
||||
: cc && count > 0
|
||||
? tokens.colorNeutralBackground3
|
||||
: fillColor,
|
||||
stroke: tokens.colorNeutralStroke1,
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
},
|
||||
pressed: {
|
||||
fill: tokens.colorBrandBackgroundPressed,
|
||||
stroke: tokens.colorNeutralBackground1,
|
||||
strokeWidth: 0.5,
|
||||
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
|
||||
stroke: tokens.colorBrandStroke1,
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{count > 0 && isFinite(cx) && isFinite(cy) && (
|
||||
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
@@ -179,15 +194,38 @@ export interface WorldMapProps {
|
||||
selectedCountry: string | null;
|
||||
/** Called when the user clicks a country or deselects. */
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
/** Ban count threshold for green coloring (default: 20). */
|
||||
thresholdLow?: number;
|
||||
/** Ban count threshold for yellow coloring (default: 50). */
|
||||
thresholdMedium?: number;
|
||||
/** Ban count threshold for red coloring (default: 100). */
|
||||
thresholdHigh?: number;
|
||||
}
|
||||
|
||||
export function WorldMap({
|
||||
countries,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow = 20,
|
||||
thresholdMedium = 50,
|
||||
thresholdHigh = 100,
|
||||
}: WorldMapProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const maxCount = Math.max(0, ...Object.values(countries));
|
||||
const [zoom, setZoom] = useState<number>(1);
|
||||
const [center, setCenter] = useState<[number, number]>([0, 0]);
|
||||
|
||||
const handleZoomIn = (): void => {
|
||||
setZoom((z) => Math.min(z + 0.5, 8));
|
||||
};
|
||||
|
||||
const handleZoomOut = (): void => {
|
||||
setZoom((z) => Math.max(z - 0.5, 1));
|
||||
};
|
||||
|
||||
const handleResetView = (): void => {
|
||||
setZoom(1);
|
||||
setCenter([0, 0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -195,6 +233,40 @@ export function WorldMap({
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||||
>
|
||||
{/* Zoom controls */}
|
||||
<div className={styles.zoomControls}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 8}
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleResetView}
|
||||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
||||
title="Reset view"
|
||||
aria-label="Reset view"
|
||||
>
|
||||
⟲
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||||
@@ -202,12 +274,25 @@ export function WorldMap({
|
||||
height={400}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
maxCount={maxCount}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
/>
|
||||
<ZoomableGroup
|
||||
zoom={zoom}
|
||||
center={center}
|
||||
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
|
||||
setZoom(newZoom);
|
||||
setCenter(coordinates);
|
||||
}}
|
||||
minZoom={1}
|
||||
maxZoom={8}
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
thresholdMedium={thresholdMedium}
|
||||
thresholdHigh={thresholdHigh}
|
||||
/>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchBans } from "../api/dashboard";
|
||||
import type { DashboardBanItem, TimeRange } from "../types/ban";
|
||||
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
|
||||
|
||||
/** Items per page for the ban table. */
|
||||
const PAGE_SIZE = 100;
|
||||
@@ -33,29 +33,33 @@ export interface UseBansResult {
|
||||
/**
|
||||
* Fetch and manage dashboard ban-list data.
|
||||
*
|
||||
* Automatically re-fetches when `timeRange` or `page` changes.
|
||||
* Automatically re-fetches when `timeRange`, `origin`, or `page` changes.
|
||||
*
|
||||
* @param timeRange - Time-range preset that controls how far back to look.
|
||||
* @param origin - Origin filter (default `"all"`).
|
||||
* @returns Current data, pagination state, loading flag, and a `refresh`
|
||||
* callback.
|
||||
*/
|
||||
export function useBans(timeRange: TimeRange): UseBansResult {
|
||||
export function useBans(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
): UseBansResult {
|
||||
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset page when time range changes.
|
||||
// Reset page when time range or origin filter changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [timeRange]);
|
||||
}, [timeRange, origin]);
|
||||
|
||||
const doFetch = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchBans(timeRange, page, PAGE_SIZE);
|
||||
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin);
|
||||
setBanItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err: unknown) {
|
||||
@@ -63,7 +67,7 @@ export function useBans(timeRange: TimeRange): UseBansResult {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange, page]);
|
||||
}, [timeRange, page, origin]);
|
||||
|
||||
// Stable ref to the latest doFetch so the refresh callback is always current.
|
||||
const doFetchRef = useRef(doFetch);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchBansByCountry } from "../api/map";
|
||||
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return type
|
||||
@@ -31,7 +32,10 @@ export interface UseMapDataResult {
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
|
||||
export function useMapData(
|
||||
range: TimeRange = "24h",
|
||||
origin: BanOriginFilter = "all",
|
||||
): UseMapDataResult {
|
||||
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -43,7 +47,7 @@ export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBansByCountry(range)
|
||||
fetchBansByCountry(range, origin)
|
||||
.then((resp) => {
|
||||
setData(resp);
|
||||
})
|
||||
@@ -55,7 +59,7 @@ export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
|
||||
.finally((): void => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [range]);
|
||||
}, [range, origin]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Jails — per-jail config accordion with inline editing
|
||||
* Global — global fail2ban settings (log level, DB config)
|
||||
* Server — server-level settings + flush logs
|
||||
* Map — map color threshold configuration
|
||||
* Regex Tester — live pattern tester
|
||||
*/
|
||||
|
||||
@@ -44,10 +45,15 @@ import {
|
||||
useRegexTester,
|
||||
useServerSettings,
|
||||
} from "../hooks/useConfig";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
} from "../api/config";
|
||||
import type {
|
||||
GlobalConfigUpdate,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
MapColorThresholdsUpdate,
|
||||
ServerSettingsUpdate,
|
||||
} from "../types/config";
|
||||
|
||||
@@ -766,6 +772,156 @@ function ServerTab(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MapTab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MapTab(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [thresholdHigh, setThresholdHigh] = useState<string>("100");
|
||||
const [thresholdMedium, setThresholdMedium] = useState<string>("50");
|
||||
const [thresholdLow, setThresholdLow] = useState<string>("20");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
// Load current thresholds on mount
|
||||
useEffect(() => {
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const thresholds = await fetchMapColorThresholds();
|
||||
setThresholdHigh(String(thresholds.threshold_high));
|
||||
setThresholdMedium(String(thresholds.threshold_medium));
|
||||
setThresholdLow(String(thresholds.threshold_low));
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
text: err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
const high = Number(thresholdHigh);
|
||||
const medium = Number(thresholdMedium);
|
||||
const low = Number(thresholdLow);
|
||||
|
||||
if (isNaN(high) || isNaN(medium) || isNaN(low)) {
|
||||
setMessage({ text: "All thresholds must be valid numbers.", type: "error" });
|
||||
return;
|
||||
}
|
||||
if (high <= 0 || medium <= 0 || low <= 0) {
|
||||
setMessage({ text: "All thresholds must be positive integers.", type: "error" });
|
||||
return;
|
||||
}
|
||||
if (!(high > medium && medium > low)) {
|
||||
setMessage({
|
||||
text: "Thresholds must satisfy: high > medium > low.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const update: MapColorThresholdsUpdate = {
|
||||
threshold_high: high,
|
||||
threshold_medium: medium,
|
||||
threshold_low: low,
|
||||
};
|
||||
await updateMapColorThresholds(update);
|
||||
setMessage({ text: "Map color thresholds saved successfully.", type: "success" });
|
||||
} catch (err) {
|
||||
setMessage({
|
||||
text: err instanceof ApiError ? err.message : "Failed to save map color thresholds",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Spinner label="Loading map settings…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Map Color Thresholds
|
||||
</Text>
|
||||
<Text as="p" size={300} className={styles.infoText} block style={{ marginBottom: tokens.spacingVerticalM }}>
|
||||
Configure the ban count thresholds that determine country fill colors on the World Map.
|
||||
Countries with zero bans remain transparent. Colors smoothly interpolate between thresholds.
|
||||
</Text>
|
||||
|
||||
{message && (
|
||||
<MessageBar intent={message.type === "error" ? "error" : "success"}>
|
||||
<MessageBarBody>{message.text}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Low Threshold (Green)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdLow}
|
||||
onChange={(_, d) => {
|
||||
setThresholdLow(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Medium Threshold (Yellow)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdMedium}
|
||||
onChange={(_, d) => {
|
||||
setThresholdMedium(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="High Threshold (Red)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdHigh}
|
||||
onChange={(_, d) => {
|
||||
setThresholdHigh(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Text as="p" size={200} className={styles.infoText} style={{ marginTop: tokens.spacingVerticalS }}>
|
||||
• 1 to {thresholdLow}: Light green → Full green<br />
|
||||
• {thresholdLow} to {thresholdMedium}: Green → Yellow<br />
|
||||
• {thresholdMedium} to {thresholdHigh}: Yellow → Red<br />
|
||||
• {thresholdHigh}+: Solid red
|
||||
</Text>
|
||||
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Save24Regular />}
|
||||
disabled={saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Thresholds"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RegexTesterTab
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -958,7 +1114,7 @@ function RegexTesterTab(): React.JSX.Element {
|
||||
// ConfigPage (root)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabValue = "jails" | "global" | "server" | "regex";
|
||||
type TabValue = "jails" | "global" | "server" | "map" | "regex";
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
@@ -985,6 +1141,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
<Tab value="jails">Jails</Tab>
|
||||
<Tab value="global">Global</Tab>
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
<Tab value="regex">Regex Tester</Tab>
|
||||
</TabList>
|
||||
|
||||
@@ -992,6 +1149,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
{tab === "jails" && <JailsTab />}
|
||||
{tab === "global" && <GlobalTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
{tab === "regex" && <RegexTesterTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from "@fluentui/react-components";
|
||||
import { BanTable } from "../components/BanTable";
|
||||
import { ServerStatusBar } from "../components/ServerStatusBar";
|
||||
import type { TimeRange } from "../types/ban";
|
||||
import { TIME_RANGE_LABELS } from "../types/ban";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
import { BAN_ORIGIN_FILTER_LABELS, TIME_RANGE_LABELS } from "../types/ban";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -73,6 +73,9 @@ const useStyles = makeStyles({
|
||||
/** Ordered time-range presets for the toolbar. */
|
||||
const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
|
||||
|
||||
/** Ordered origin filter options for the toolbar. */
|
||||
const ORIGIN_FILTERS: BanOriginFilter[] = ["all", "blocklist", "selfblock"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,6 +89,7 @@ const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
|
||||
export function DashboardPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@@ -119,11 +123,28 @@ export function DashboardPage(): React.JSX.Element {
|
||||
</ToggleButton>
|
||||
))}
|
||||
</Toolbar>
|
||||
|
||||
{/* Origin filter */}
|
||||
<Toolbar aria-label="Origin filter" size="small">
|
||||
{ORIGIN_FILTERS.map((f) => (
|
||||
<ToggleButton
|
||||
key={f}
|
||||
size="small"
|
||||
checked={originFilter === f}
|
||||
onClick={() => {
|
||||
setOriginFilter(f);
|
||||
}}
|
||||
aria-pressed={originFilter === f}
|
||||
>
|
||||
{BAN_ORIGIN_FILTER_LABELS[f]}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</Toolbar>
|
||||
</div>
|
||||
|
||||
{/* Ban table */}
|
||||
<div className={styles.tabContent}>
|
||||
<BanTable timeRange={timeRange} />
|
||||
<BanTable timeRange={timeRange} origin={originFilter} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* bans when no country is selected).
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
@@ -29,7 +30,10 @@ import {
|
||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
||||
import { WorldMap } from "../components/WorldMap";
|
||||
import { useMapData } from "../hooks/useMapData";
|
||||
import { fetchMapColorThresholds } from "../api/config";
|
||||
import type { TimeRange } from "../types/map";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
@@ -86,10 +90,30 @@ const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
||||
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 [thresholdLow, setThresholdLow] = useState<number>(20);
|
||||
const [thresholdMedium, setThresholdMedium] = useState<number>(50);
|
||||
const [thresholdHigh, setThresholdHigh] = useState<number>(100);
|
||||
|
||||
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||
useMapData(range);
|
||||
useMapData(range, originFilter);
|
||||
|
||||
// Fetch color thresholds on mount
|
||||
useEffect(() => {
|
||||
const loadThresholds = async (): Promise<void> => {
|
||||
try {
|
||||
const thresholds = await fetchMapColorThresholds();
|
||||
setThresholdLow(thresholds.threshold_low);
|
||||
setThresholdMedium(thresholds.threshold_medium);
|
||||
setThresholdHigh(thresholds.threshold_high);
|
||||
} catch (err) {
|
||||
// Silently fall back to defaults if fetch fails
|
||||
console.warn("Failed to load map color thresholds:", err);
|
||||
}
|
||||
};
|
||||
void loadThresholds();
|
||||
}, []);
|
||||
|
||||
/** Bans visible in the companion table (filtered by selected country). */
|
||||
const visibleBans = useMemo(() => {
|
||||
@@ -128,6 +152,23 @@ export function MapPage(): React.JSX.Element {
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Origin filter */}
|
||||
<Select
|
||||
aria-label="Origin filter"
|
||||
value={originFilter}
|
||||
onChange={(_ev, data): void => {
|
||||
setOriginFilter(data.value as BanOriginFilter);
|
||||
setSelectedCountry(null);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{BAN_ORIGIN_FILTER_LABELS[f]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<ArrowCounterclockwiseRegular />}
|
||||
onClick={(): void => {
|
||||
@@ -162,6 +203,9 @@ export function MapPage(): React.JSX.Element {
|
||||
countries={countries}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={setSelectedCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
thresholdMedium={thresholdMedium}
|
||||
thresholdHigh={thresholdHigh}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -211,13 +255,14 @@ export function MapPage(): React.JSX.Element {
|
||||
<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={5}>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCellLayout>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No bans found.
|
||||
@@ -244,6 +289,16 @@ export function MapPage(): React.JSX.Element {
|
||||
{ban.country_name ?? ban.country_code ?? "—"}
|
||||
</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>
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
/** The four supported time-range presets for dashboard views. */
|
||||
export type TimeRange = "24h" | "7d" | "30d" | "365d";
|
||||
|
||||
/**
|
||||
* Filter for the origin of a ban.
|
||||
*
|
||||
* - `"all"` — no filter, show all bans.
|
||||
* - `"blocklist"` — only bans from the blocklist-import jail.
|
||||
* - `"selfblock"` — only bans detected by fail2ban itself.
|
||||
*/
|
||||
export type BanOriginFilter = "all" | "blocklist" | "selfblock";
|
||||
|
||||
/** Human-readable labels for each origin filter option. */
|
||||
export const BAN_ORIGIN_FILTER_LABELS: Record<BanOriginFilter, string> = {
|
||||
all: "All",
|
||||
blocklist: "Blocklist",
|
||||
selfblock: "Selfblock",
|
||||
} as const;
|
||||
|
||||
/** Human-readable labels for each time-range preset. */
|
||||
export const TIME_RANGE_LABELS: Record<TimeRange, string> = {
|
||||
"24h": "Last 24 h",
|
||||
@@ -47,6 +63,8 @@ export interface DashboardBanItem {
|
||||
org: string | null;
|
||||
/** How many times this IP was banned. */
|
||||
ban_count: number;
|
||||
/** Whether this ban came from a blocklist import or fail2ban itself. */
|
||||
origin: "blocklist" | "selfblock";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,3 +128,19 @@ export interface AddLogPathRequest {
|
||||
log_path: string;
|
||||
tail?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map Color Thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MapColorThresholdsResponse {
|
||||
threshold_high: number;
|
||||
threshold_medium: number;
|
||||
threshold_low: number;
|
||||
}
|
||||
|
||||
export interface MapColorThresholdsUpdate {
|
||||
threshold_high: number;
|
||||
threshold_medium: number;
|
||||
threshold_low: number;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface MapBanItem {
|
||||
asn: string | null;
|
||||
org: string | null;
|
||||
ban_count: number;
|
||||
/** Whether this ban came from a blocklist import or fail2ban itself. */
|
||||
origin: "blocklist" | "selfblock";
|
||||
}
|
||||
|
||||
/** Response from GET /api/dashboard/bans/by-country */
|
||||
|
||||
99
frontend/src/utils/mapColors.ts
Normal file
99
frontend/src/utils/mapColors.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Map color utilities for World Map visualization.
|
||||
*
|
||||
* Provides color interpolation logic that maps ban counts to colors based on
|
||||
* configurable thresholds. Countries with zero bans remain transparent;
|
||||
* non-zero counts are interpolated through green → yellow → red color stops.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interpolate a value between two numbers.
|
||||
*
|
||||
* @param start - Start value
|
||||
* @param end - End value
|
||||
* @param t - Interpolation factor in [0, 1]
|
||||
* @returns The interpolated value
|
||||
*/
|
||||
function lerp(start: number, end: number, t: number): number {
|
||||
return start + (end - start) * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB values to hex color string.
|
||||
*
|
||||
* @param r - Red component (0-255)
|
||||
* @param g - Green component (0-255)
|
||||
* @param b - Blue component (0-255)
|
||||
* @returns Hex color string in format "#RRGGBB"
|
||||
*/
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const toHex = (n: number): string => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16);
|
||||
return hex.length === 1 ? "0" + hex : hex;
|
||||
};
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the fill color for a country based on its ban count.
|
||||
*
|
||||
* Returns "transparent" for zero bans. For non-zero counts, interpolates
|
||||
* through color stops:
|
||||
* - 1 to threshold_low: light green → full green
|
||||
* - threshold_low to threshold_medium: green → yellow
|
||||
* - threshold_medium to threshold_high: yellow → red
|
||||
* - threshold_high or more: solid red
|
||||
*
|
||||
* @param banCount - Number of bans for the country
|
||||
* @param thresholdLow - Ban count for green coloring (default: 20)
|
||||
* @param thresholdMedium - Ban count for yellow coloring (default: 50)
|
||||
* @param thresholdHigh - Ban count for red coloring (default: 100)
|
||||
* @returns Hex color string or "transparent"
|
||||
*/
|
||||
export function getBanCountColor(
|
||||
banCount: number,
|
||||
thresholdLow: number = 20,
|
||||
thresholdMedium: number = 50,
|
||||
thresholdHigh: number = 100,
|
||||
): string {
|
||||
// Zero bans → transparent (no fill)
|
||||
if (banCount === 0) {
|
||||
return "transparent";
|
||||
}
|
||||
|
||||
// Color stops
|
||||
const lightGreen = { r: 144, g: 238, b: 144 }; // #90EE90
|
||||
const green = { r: 0, g: 128, b: 0 }; // #008000
|
||||
const yellow = { r: 255, g: 255, b: 0 }; // #FFFF00
|
||||
const red = { r: 220, g: 20, b: 60 }; // #DC143C (crimson)
|
||||
|
||||
// 1 to threshold_low: interpolate light green → green
|
||||
if (banCount <= thresholdLow) {
|
||||
const t = (banCount - 1) / (thresholdLow - 1);
|
||||
const r = lerp(lightGreen.r, green.r, t);
|
||||
const g = lerp(lightGreen.g, green.g, t);
|
||||
const b = lerp(lightGreen.b, green.b, t);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// threshold_low to threshold_medium: interpolate green → yellow
|
||||
if (banCount <= thresholdMedium) {
|
||||
const t = (banCount - thresholdLow) / (thresholdMedium - thresholdLow);
|
||||
const r = lerp(green.r, yellow.r, t);
|
||||
const g = lerp(green.g, yellow.g, t);
|
||||
const b = lerp(green.b, yellow.b, t);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// threshold_medium to threshold_high: interpolate yellow → red
|
||||
if (banCount <= thresholdHigh) {
|
||||
const t = (banCount - thresholdMedium) / (thresholdHigh - thresholdMedium);
|
||||
const r = lerp(yellow.r, red.r, t);
|
||||
const g = lerp(yellow.g, red.g, t);
|
||||
const b = lerp(yellow.b, red.b, t);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// threshold_high or more: solid red
|
||||
return rgbToHex(red.r, red.g, red.b);
|
||||
}
|
||||
Reference in New Issue
Block a user