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