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:
2026-03-07 20:03:43 +01:00
parent 706d2e1df8
commit 53d664de4f
28 changed files with 1637 additions and 103 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>