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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user