Fix blocklist-import bantime, unify filter bar, and improve config navigation

This commit is contained in:
2026-03-17 11:31:46 +01:00
parent e98fd1de93
commit bf82e38b6e
21 changed files with 566 additions and 164 deletions

View File

@@ -18,8 +18,8 @@ logpath = /dev/null
backend = auto backend = auto
maxretry = 1 maxretry = 1
findtime = 1d findtime = 1d
# Block imported IPs for one week. # Block imported IPs for 24 hours.
bantime = 1w bantime = 86400
# Never ban the Docker bridge network or localhost. # Never ban the Docker bridge network or localhost.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -12,3 +12,81 @@ This document breaks the entire BanGUI project into development stages, ordered
--- ---
### Task 1 — Blocklist-import jail ban time must be 24 hours
**Status:** ✅ Done
**Context**
When the blocklist importer bans an IP it calls `jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, ip)` (see `backend/app/services/blocklist_service.py`, constant `BLOCKLIST_JAIL = "blocklist-import"`). That call sends `set blocklist-import banip <ip>` to fail2ban, which applies the jail's configured `bantime`. There is currently no guarantee that the `blocklist-import` jail's `bantime` is 86 400 s (24 h), so imported IPs may be released too early or held indefinitely depending on the jail template.
**What to do**
1. Locate every place the `blocklist-import` jail is defined or provisioned — check `Docker/fail2ban-dev-config/`, `Docker/Dockerfile.backend`, any jail template files, and the `setup_service.py` / `SetupPage.tsx` flow.
2. Ensure the `blocklist-import` jail is created with `bantime = 86400` (24 h). If the jail is created at runtime by the setup service, add or update the `bantime` parameter there. If it is defined in a static config file, set `bantime = 86400` in that file.
3. Verify that the existing `jail_service.ban_ip` call in `blocklist_service.import_source` does not need a per-call duration override; the jail-level default of 86 400 s is sufficient.
4. Add or update the relevant unit/integration test in `backend/tests/` to assert that the blocklist-import jail is set up with a 24-hour bantime.
---
### Task 2 — Clicking a jail in Jail Overview navigates to Configuration → Jails
**Status:** ✅ Done
**Context**
`JailsPage.tsx` renders a "Jail Overview" data grid with one row per jail (see `frontend/src/pages/JailsPage.tsx`). Clicking a row currently does nothing. `ConfigPage.tsx` hosts a tab bar with a "Jails" tab that renders `JailsTab`, which already uses a list/detail layout where a jail can be selected from the left pane.
**What to do**
1. In `JailsPage.tsx`, make each jail name cell (or the entire row) a clickable element that navigates to `/config` with state `{ tab: "jails", jail: "<jailName>" }`. Use `useNavigate` from `react-router-dom`; the existing `Link` import can be used or replaced with a programmatic navigate.
2. In `ConfigPage.tsx`, read the location state on mount. If `state.tab` is `"jails"`, set the active tab to `"jails"`. Pass `state.jail` down to `<JailsTab initialJail={state.jail} />`.
3. In `JailsTab.tsx`, accept an optional `initialJail?: string` prop. When it is provided, pre-select that jail in the left-pane list on first render (i.e. set the selected jail state to the jail whose name matches `initialJail`). This should scroll the item into view if the list is long.
4. Add a frontend unit test in `frontend/src/pages/__tests__/` that mounts `JailsPage` with a mocked jail list, clicks a jail row, and asserts that `useNavigate` was called with the correct path and state.
---
### Task 3 — Setting bantime / findtime throws 400 error due to unsupported `backend` set command
**Status:** ✅ Done
**Context**
Editing ban time or find time in Configuration → Jails triggers an auto-save that sends the full `JailConfigUpdate` payload including the `backend` field. `config_service.update_jail_config` then calls `set <jail> backend <value>` on the fail2ban socket, which returns error code 1 with the message `Invalid command 'backend' (no set action or not yet implemented)`. Fail2ban does not support changing a jail's backend at runtime; it must be set before the jail starts.
**What to do**
**Backend** (`backend/app/services/config_service.py`):
1. Remove the `if update.backend is not None: await _set("backend", update.backend)` block from `update_jail_config`. Setting `backend` via the socket is not supported by fail2ban and will always fail.
2. `log_encoding` has the same constraint — verify whether `set <jail> logencoding` is supported at runtime. If it is not, remove it too. If it is supported, leave it.
3. Ensure the function still accepts and stores the `backend` value in the Pydantic model for read purposes; do not remove it from `JailConfigUpdate` or the response model.
**Frontend** (`frontend/src/components/config/JailsTab.tsx`):
4. Remove `backend` (and `log_encoding` if step 2 confirms it is unsupported) from the `autoSavePayload` memo so the field is never sent in the PATCH/PUT body. The displayed value should remain read-only — show them as plain text or a disabled select so the user can see the current value without being able to trigger the broken set command.
**Tests**:
5. Add or update the backend test for `update_jail_config` to assert that no `set … backend` command is issued, and that a payload containing a `backend` field does not cause an error.
---
### Task 4 — Unify filter bar: use `DashboardFilterBar` in World Map and History pages
**Status:** ✅ Done
**Context**
`DashboardPage.tsx` uses the shared `<DashboardFilterBar>` component for its time-range and origin-filter controls. `MapPage.tsx` and `HistoryPage.tsx` each implement their own ad-hoc filter UI: `MapPage` uses a Fluent UI `<Select>` for time range plus an inline Toolbar for origin filter; `HistoryPage` uses a `<Select>` for time range with no origin filter toggle. The `DashboardFilterBar` already supports both `TimeRange` and `BanOriginFilter` with the exact toggle-button style shown in the design reference. All three pages should share the same filter appearance and interaction patterns.
**What to do**
1. **`MapPage.tsx`**: Replace the custom time-range `<Select>` and the inline origin-filter Toolbar with `<DashboardFilterBar timeRange={range} onTimeRangeChange={setRange} originFilter={originFilter} onOriginFilterChange={setOriginFilter} />`. Remove the now-unused `TIME_RANGE_OPTIONS` constant and the `BAN_ORIGIN_FILTER_LABELS` inline usage. Pass `originFilter` to `useMapData` if it does not already receive it (check the hook signature).
2. **`HistoryPage.tsx`**: Replace the custom time-range `<Select>` with `<DashboardFilterBar>`. Add an `originFilter` state (`BanOriginFilter`, default `"all"`) and wire it through `<DashboardFilterBar onOriginFilterChange={setOriginFilter} />`. Pass the origin filter into the `useHistory` query so the backend receives it. If `useHistory` / `HistoryQuery` does not yet accept `origin_filter`, add the parameter to the type and the hook's fetch call.
3. Remove any local `filterBar` style definitions from `MapPage.tsx` and `HistoryPage.tsx` that duplicate what `DashboardFilterBar` already provides.
4. Ensure the `DashboardFilterBar` component's props interface (`DashboardFilterBarProps` in `frontend/src/components/DashboardFilterBar.tsx`) is not changed in a breaking way; only the call sites change.
5. Update or add component tests for `MapPage` and `HistoryPage` to assert that `DashboardFilterBar` is rendered and that changing the time range or origin filter updates the displayed data.
---

View File

@@ -23,7 +23,7 @@ if TYPE_CHECKING:
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from app.dependencies import AuthDep from app.dependencies import AuthDep
from app.models.ban import TimeRange from app.models.ban import BanOrigin, TimeRange
from app.models.history import HistoryListResponse, IpDetailResponse from app.models.history import HistoryListResponse, IpDetailResponse
from app.services import geo_service, history_service from app.services import geo_service, history_service
@@ -52,6 +52,10 @@ async def get_history(
default=None, default=None,
description="Restrict results to IPs matching this prefix.", description="Restrict results to IPs matching this prefix.",
), ),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
),
page: int = Query(default=1, ge=1, description="1-based page number."), page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query( page_size: int = Query(
default=_DEFAULT_PAGE_SIZE, default=_DEFAULT_PAGE_SIZE,
@@ -89,6 +93,7 @@ async def get_history(
range_=range, range_=range,
jail=jail, jail=jail,
ip_filter=ip, ip_filter=ip,
origin=origin,
page=page, page=page,
page_size=page_size, page_size=page_size,
geo_enricher=_enricher, geo_enricher=_enricher,

View File

@@ -368,8 +368,9 @@ async def update_jail_config(
await _set("datepattern", update.date_pattern) await _set("datepattern", update.date_pattern)
if update.dns_mode is not None: if update.dns_mode is not None:
await _set("usedns", update.dns_mode) await _set("usedns", update.dns_mode)
if update.backend is not None: # Fail2ban does not support changing the log monitoring backend at runtime.
await _set("backend", update.backend) # The configuration value is retained for read/display purposes but must not
# be applied via the socket API.
if update.log_encoding is not None: if update.log_encoding is not None:
await _set("logencoding", update.log_encoding) await _set("logencoding", update.log_encoding)
if update.prefregex is not None: if update.prefregex is not None:

View File

@@ -16,7 +16,7 @@ from typing import Any
import aiosqlite import aiosqlite
import structlog import structlog
from app.models.ban import TIME_RANGE_SECONDS, TimeRange from app.models.ban import BLOCKLIST_JAIL, BanOrigin, TIME_RANGE_SECONDS, TimeRange
from app.models.history import ( from app.models.history import (
HistoryBanItem, HistoryBanItem,
HistoryListResponse, HistoryListResponse,
@@ -58,6 +58,7 @@ async def list_history(
*, *,
range_: TimeRange | None = None, range_: TimeRange | None = None,
jail: str | None = None, jail: str | None = None,
origin: BanOrigin | None = None,
ip_filter: str | None = None, ip_filter: str | None = None,
page: int = 1, page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE, page_size: int = _DEFAULT_PAGE_SIZE,
@@ -73,6 +74,8 @@ async def list_history(
socket_path: Path to the fail2ban Unix domain socket. socket_path: Path to the fail2ban Unix domain socket.
range_: Time-range preset. ``None`` means all-time (no time filter). range_: Time-range preset. ``None`` means all-time (no time filter).
jail: If given, restrict results to bans from this jail. jail: If given, restrict results to bans from this jail.
origin: Optional origin filter — ``"blocklist"`` restricts results to
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
ip_filter: If given, restrict results to bans for this exact IP ip_filter: If given, restrict results to bans for this exact IP
(or a prefix — the query uses ``LIKE ip_filter%``). (or a prefix — the query uses ``LIKE ip_filter%``).
page: 1-based page number (default: ``1``). page: 1-based page number (default: ``1``).
@@ -99,6 +102,14 @@ async def list_history(
wheres.append("jail = ?") wheres.append("jail = ?")
params.append(jail) params.append(jail)
if origin is not None:
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if ip_filter is not None: if ip_filter is not None:
wheres.append("ip LIKE ?") wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%") params.append(f"{ip_filter}%")

View File

@@ -49,7 +49,7 @@ logpath = /dev/null
backend = auto backend = auto
maxretry = 1 maxretry = 1
findtime = 1d findtime = 1d
bantime = 1w bantime = 86400
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
""" """

View File

@@ -213,6 +213,18 @@ class TestHistoryList:
_args, kwargs = mock_fn.call_args _args, kwargs = mock_fn.call_args
assert kwargs.get("range_") == "7d" assert kwargs.get("range_") == "7d"
async def test_forwards_origin_filter(self, history_client: AsyncClient) -> None:
"""The ``origin`` query parameter is forwarded to the service."""
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
with patch(
"app.routers.history.history_service.list_history",
new=mock_fn,
):
await history_client.get("/api/history?origin=blocklist")
_args, kwargs = mock_fn.call_args
assert kwargs.get("origin") == "blocklist"
async def test_empty_result(self, history_client: AsyncClient) -> None: async def test_empty_result(self, history_client: AsyncClient) -> None:
"""An empty history returns items=[] and total=0.""" """An empty history returns items=[] and total=0."""
with patch( with patch(

View File

@@ -256,6 +256,27 @@ class TestUpdateJailConfig:
assert "bantime" in keys assert "bantime" in keys
assert "maxretry" in keys assert "maxretry" in keys
async def test_ignores_backend_field(self) -> None:
"""update_jail_config does not send a set command for backend."""
sent_commands: list[list[Any]] = []
async def _send(command: list[Any]) -> Any:
sent_commands.append(command)
return (0, "OK")
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
from app.models.config import JailConfigUpdate
update = JailConfigUpdate(backend="polling")
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
await config_service.update_jail_config(_SOCKET, "sshd", update)
keys = [cmd[2] for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
assert "backend" not in keys
async def test_raises_validation_error_on_bad_regex(self) -> None: async def test_raises_validation_error_on_bad_regex(self) -> None:
"""update_jail_config raises ConfigValidationError for invalid regex.""" """update_jail_config raises ConfigValidationError for invalid regex."""
from app.models.config import JailConfigUpdate from app.models.config import JailConfigUpdate

View File

@@ -65,6 +65,10 @@ class TestEnsureJailConfigs:
content = _read(jail_d, conf_file) content = _read(jail_d, conf_file)
assert "enabled = false" in content assert "enabled = false" in content
# Blocklist-import jail must have a 24-hour ban time
blocklist_conf = _read(jail_d, _BLOCKLIST_CONF)
assert "bantime = 86400" in blocklist_conf
# .local files must set enabled = true and nothing else # .local files must set enabled = true and nothing else
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL): for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
content = _read(jail_d, local_file) content = _read(jail_d, local_file)

View File

@@ -18,6 +18,7 @@ export async function fetchHistory(
): Promise<HistoryListResponse> { ): Promise<HistoryListResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query.range) params.set("range", query.range); if (query.range) params.set("range", query.range);
if (query.origin) params.set("origin", query.origin);
if (query.jail) params.set("jail", query.jail); if (query.jail) params.set("jail", query.jail);
if (query.ip) params.set("ip", query.ip); if (query.ip) params.set("ip", query.ip);
if (query.page !== undefined) params.set("page", String(query.page)); if (query.page !== undefined) params.set("page", String(query.page));

View File

@@ -216,7 +216,6 @@ function JailConfigDetail({
ignore_regex: ignoreRegex, ignore_regex: ignoreRegex,
date_pattern: datePattern !== "" ? datePattern : null, date_pattern: datePattern !== "" ? datePattern : null,
dns_mode: dnsMode, dns_mode: dnsMode,
backend,
log_encoding: logEncoding, log_encoding: logEncoding,
prefregex: prefRegex !== "" ? prefRegex : null, prefregex: prefRegex !== "" ? prefRegex : null,
bantime_escalation: { bantime_escalation: {
@@ -231,7 +230,7 @@ function JailConfigDetail({
}), }),
[ [
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern, banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor, dnsMode, logEncoding, prefRegex, escEnabled, escFactor,
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails, escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
jail.ban_time, jail.find_time, jail.max_retry, jail.ban_time, jail.find_time, jail.max_retry,
], ],
@@ -758,7 +757,12 @@ function InactiveJailDetail({
* *
* @returns JSX element. * @returns JSX element.
*/ */
export function JailsTab(): React.JSX.Element { interface JailsTabProps {
/** Jail name to pre-select when the component mounts. */
initialJail?: string;
}
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail } = const { jails, loading, error, refresh, updateJail } =
useJailConfigs(); useJailConfigs();
@@ -819,6 +823,13 @@ export function JailsTab(): React.JSX.Element {
return [...activeItems, ...inactiveItems]; return [...activeItems, ...inactiveItems];
}, [jails, inactiveJails]); }, [jails, inactiveJails]);
useEffect(() => {
if (!initialJail || selectedName) return;
if (listItems.some((item) => item.name === initialJail)) {
setSelectedName(initialJail);
}
}, [initialJail, listItems, selectedName]);
const activeJailMap = useMemo( const activeJailMap = useMemo(
() => new Map(jails.map((j) => [j.name, j])), () => new Map(jails.map((j) => [j.name, j])),
[jails], [jails],

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { JailsTab } from "../JailsTab";
import type { JailConfig } from "../../../types/config";
import { useAutoSave } from "../../../hooks/useAutoSave";
import { useJailConfigs } from "../../../hooks/useConfig";
import { useConfigActiveStatus } from "../../../hooks/useConfigActiveStatus";
vi.mock("../../../hooks/useAutoSave");
vi.mock("../../../hooks/useConfig");
vi.mock("../../../hooks/useConfigActiveStatus");
vi.mock("../../../api/config", () => ({
fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [] }),
deactivateJail: vi.fn(),
deleteJailLocalOverride: vi.fn(),
addLogPath: vi.fn(),
deleteLogPath: vi.fn(),
fetchJailConfigFileContent: vi.fn(),
updateJailConfigFile: vi.fn(),
validateJailConfig: vi.fn(),
}));
const mockUseAutoSave = vi.mocked(useAutoSave);
const mockUseJailConfigs = vi.mocked(useJailConfigs);
const mockUseConfigActiveStatus = vi.mocked(useConfigActiveStatus);
const basicJail: JailConfig = {
name: "sshd",
ban_time: 600,
max_retry: 5,
find_time: 600,
fail_regex: [],
ignore_regex: [],
log_paths: [],
date_pattern: null,
log_encoding: "auto",
backend: "polling",
use_dns: "warn",
prefregex: "",
actions: [],
bantime_escalation: null,
};
describe("JailsTab", () => {
it("does not include backend in auto-save payload", () => {
const autoSavePayloads: Array<Record<string, unknown>> = [];
mockUseAutoSave.mockImplementation((value) => {
autoSavePayloads.push(value as Record<string, unknown>);
return { status: "idle", errorText: null, retry: vi.fn() };
});
mockUseJailConfigs.mockReturnValue({
jails: [basicJail],
total: 1,
loading: false,
error: null,
refresh: vi.fn(),
updateJail: vi.fn(),
reloadAll: vi.fn(),
});
mockUseConfigActiveStatus.mockReturnValue({ activeJails: [] });
render(
<FluentProvider theme={webLightTheme}>
<JailsTab initialJail="sshd" />
</FluentProvider>,
);
expect(autoSavePayloads.length).toBeGreaterThan(0);
const lastPayload = autoSavePayloads[autoSavePayloads.length - 1];
expect(lastPayload).not.toHaveProperty("backend");
});
});

View File

@@ -13,7 +13,8 @@
* Export — raw file editors for jail, filter, and action files * Export — raw file editors for jail, filter, and action files
*/ */
import { useState } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components"; import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
import { import {
ActionsTab, ActionsTab,
@@ -58,8 +59,16 @@ type TabValue =
export function ConfigPage(): React.JSX.Element { export function ConfigPage(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
const location = useLocation();
const [tab, setTab] = useState<TabValue>("jails"); const [tab, setTab] = useState<TabValue>("jails");
useEffect(() => {
const state = location.state as { tab?: string; jail?: string } | null;
if (state?.tab === "jails") {
setTab("jails");
}
}, [location.state]);
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.header}> <div className={styles.header}>
@@ -86,7 +95,11 @@ export function ConfigPage(): React.JSX.Element {
</TabList> </TabList>
<div className={styles.tabContent} key={tab}> <div className={styles.tabContent} key={tab}>
{tab === "jails" && <JailsTab />} {tab === "jails" && (
<JailsTab
initialJail={(location.state as { jail?: string } | null)?.jail}
/>
)}
{tab === "filters" && <FiltersTab />} {tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />} {tab === "actions" && <ActionsTab />}
{tab === "server" && <ServerTab />} {tab === "server" && <ServerTab />}

View File

@@ -19,7 +19,6 @@ import {
Input, Input,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
Select,
Spinner, Spinner,
Table, Table,
TableBody, TableBody,
@@ -30,8 +29,6 @@ import {
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
Text, Text,
Toolbar,
ToolbarButton,
createTableColumn, createTableColumn,
makeStyles, makeStyles,
tokens, tokens,
@@ -42,8 +39,10 @@ import {
ChevronLeftRegular, ChevronLeftRegular,
ChevronRightRegular, ChevronRightRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { useHistory, useIpHistory } from "../hooks/useHistory"; import { useHistory, useIpHistory } from "../hooks/useHistory";
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history"; import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
import type { BanOriginFilter } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -54,13 +53,6 @@ const HIGH_BAN_THRESHOLD = 5;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
{ label: "Last 24 hours", value: "24h" },
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
{ label: "Last 365 days", value: "365d" },
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles // Styles
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -381,7 +373,8 @@ export function HistoryPage(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
// Filter state // Filter state
const [range, setRange] = useState<TimeRange | undefined>(undefined); const [range, setRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState(""); const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState(""); const [ipFilter, setIpFilter] = useState("");
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({ const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
@@ -397,11 +390,12 @@ export function HistoryPage(): React.JSX.Element {
const applyFilters = useCallback((): void => { const applyFilters = useCallback((): void => {
setAppliedQuery({ setAppliedQuery({
range: range, range: range,
origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined, jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined, ip: ipFilter.trim() || undefined,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,
}); });
}, [range, jailFilter, ipFilter]); }, [range, originFilter, jailFilter, ipFilter]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
@@ -452,24 +446,16 @@ export function HistoryPage(): React.JSX.Element {
{/* Filter bar */} {/* Filter bar */}
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}
<div className={styles.filterRow}> <div className={styles.filterRow}>
<div className={styles.filterLabel}> <DashboardFilterBar
<Text size={200}>Time range</Text> timeRange={range}
<Select onTimeRangeChange={(value) => {
aria-label="Time range" setRange(value);
value={range ?? ""}
onChange={(_ev, data): void => {
setRange(data.value === "" ? undefined : (data.value as TimeRange));
}} }}
size="small" originFilter={originFilter}
> onOriginFilterChange={(value) => {
<option value="">All time</option> setOriginFilter(value);
{TIME_RANGE_OPTIONS.map((o) => ( }}
<option key={o.value} value={o.value}> />
{o.label}
</option>
))}
</Select>
</div>
<div className={styles.filterLabel}> <div className={styles.filterLabel}>
<Text size={200}>Jail</Text> <Text size={200}>Jail</Text>
@@ -506,7 +492,8 @@ export function HistoryPage(): React.JSX.Element {
appearance="subtle" appearance="subtle"
size="small" size="small"
onClick={(): void => { onClick={(): void => {
setRange(undefined); setRange("24h");
setOriginFilter("all");
setJailFilter(""); setJailFilter("");
setIpFilter(""); setIpFilter("");
setAppliedQuery({ page_size: PAGE_SIZE }); setAppliedQuery({ page_size: PAGE_SIZE });

View File

@@ -9,7 +9,7 @@
* geo-location details. * geo-location details.
*/ */
import { useState } from "react"; import { useMemo, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -42,7 +42,7 @@ import {
SearchRegular, SearchRegular,
StopRegular, StopRegular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { Link } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails"; import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { JailSummary } from "../types/jail"; import type { JailSummary } from "../types/jail";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
@@ -152,19 +152,38 @@ function fmtSeconds(s: number): string {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Jail overview columns // Sub-component: Jail overview section
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const jailColumns: TableColumnDefinition<JailSummary>[] = [ function JailOverviewSection(): React.JSX.Element {
const styles = useStyles();
const navigate = useNavigate();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails();
const [opError, setOpError] = useState<string | null>(null);
const jailColumns = useMemo<TableColumnDefinition<JailSummary>[]>(
() => [
createTableColumn<JailSummary>({ createTableColumn<JailSummary>({
columnId: "name", columnId: "name",
renderHeaderCell: () => "Jail", renderHeaderCell: () => "Jail",
renderCell: (j) => ( renderCell: (j) => (
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}> <Button
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}> appearance="transparent"
size="small"
style={{ padding: 0, minWidth: 0, justifyContent: "flex-start" }}
onClick={() =>
navigate("/config", {
state: { tab: "jails", jail: j.name },
})
}
>
<Text
style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}
>
{j.name} {j.name}
</Text> </Text>
</Link> </Button>
), ),
}), }),
createTableColumn<JailSummary>({ createTableColumn<JailSummary>({
@@ -210,17 +229,9 @@ const jailColumns: TableColumnDefinition<JailSummary>[] = [
renderHeaderCell: () => "Max Retry", renderHeaderCell: () => "Max Retry",
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>, renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
}), }),
]; ],
[navigate],
// --------------------------------------------------------------------------- );
// Sub-component: Jail overview section
// ---------------------------------------------------------------------------
function JailOverviewSection(): React.JSX.Element {
const styles = useStyles();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
useJails();
const [opError, setOpError] = useState<string | null>(null);
const handle = (fn: () => Promise<void>): void => { const handle = (fn: () => Promise<void>): void => {
setOpError(null); setOpError(null);

View File

@@ -12,7 +12,6 @@ import {
Button, Button,
MessageBar, MessageBar,
MessageBarBody, MessageBarBody,
Select,
Spinner, Spinner,
Table, Table,
TableBody, TableBody,
@@ -22,19 +21,16 @@ import {
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
Text, Text,
Toolbar,
ToolbarButton,
Tooltip,
makeStyles, makeStyles,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons"; import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { WorldMap } from "../components/WorldMap"; import { WorldMap } from "../components/WorldMap";
import { useMapData } from "../hooks/useMapData"; import { useMapData } from "../hooks/useMapData";
import { fetchMapColorThresholds } from "../api/config"; import { fetchMapColorThresholds } from "../api/config";
import type { TimeRange } from "../types/map"; import type { TimeRange } from "../types/map";
import type { BanOriginFilter } from "../types/ban"; import type { BanOriginFilter } from "../types/ban";
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Styles // Styles
@@ -56,15 +52,6 @@ const useStyles = makeStyles({
flexWrap: "wrap", flexWrap: "wrap",
gap: tokens.spacingHorizontalM, gap: tokens.spacingHorizontalM,
}, },
filterBar: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
background: tokens.colorNeutralBackground3,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke2}`,
},
tableWrapper: { tableWrapper: {
overflow: "auto", overflow: "auto",
maxHeight: "420px", maxHeight: "420px",
@@ -73,17 +60,6 @@ const useStyles = makeStyles({
}, },
}); });
// ---------------------------------------------------------------------------
// Time-range options
// ---------------------------------------------------------------------------
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
{ label: "Last 24 hours", value: "24h" },
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
{ label: "Last 365 days", value: "365d" },
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MapPage // MapPage
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -136,41 +112,20 @@ export function MapPage(): React.JSX.Element {
World Map World Map
</Text> </Text>
<Toolbar size="small"> <div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
<Select <DashboardFilterBar
aria-label="Time range" timeRange={range}
value={range} onTimeRangeChange={(value) => {
onChange={(_ev, data): void => { setRange(value);
setRange(data.value as TimeRange);
setSelectedCountry(null); setSelectedCountry(null);
}} }}
size="small" originFilter={originFilter}
> onOriginFilterChange={(value) => {
{TIME_RANGE_OPTIONS.map((o) => ( setOriginFilter(value);
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
{/* Origin filter */}
<Select
aria-label="Origin filter"
value={originFilter}
onChange={(_ev, data): void => {
setOriginFilter(data.value as BanOriginFilter);
setSelectedCountry(null); setSelectedCountry(null);
}} }}
size="small" />
> <Button
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
<option key={f} value={f}>
{BAN_ORIGIN_FILTER_LABELS[f]}
</option>
))}
</Select>
<ToolbarButton
icon={<ArrowCounterclockwiseRegular />} icon={<ArrowCounterclockwiseRegular />}
onClick={(): void => { onClick={(): void => {
refresh(); refresh();
@@ -178,7 +133,7 @@ export function MapPage(): React.JSX.Element {
disabled={loading} disabled={loading}
title="Refresh" title="Refresh"
/> />
</Toolbar> </div>
</div> </div>
{/* ---------------------------------------------------------------- */} {/* ---------------------------------------------------------------- */}

View File

@@ -6,7 +6,11 @@ import { ConfigPage } from "../ConfigPage";
// Mock all tab components to avoid deep render trees and API calls. // Mock all tab components to avoid deep render trees and API calls.
vi.mock("../../components/config", () => ({ vi.mock("../../components/config", () => ({
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>, JailsTab: ({ initialJail }: { initialJail?: string }) => (
<div data-testid="jails-tab" data-initial-jail={initialJail}>
JailsTab
</div>
),
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>, FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>, ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
ServerTab: () => <div data-testid="server-tab">ServerTab</div>, ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
@@ -53,4 +57,22 @@ describe("ConfigPage", () => {
renderPage(); renderPage();
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
}); });
it("selects the Jails tab based on location state", () => {
render(
<MemoryRouter
initialEntries={[
{ pathname: "/config", state: { tab: "jails", jail: "sshd" } },
]}
>
<FluentProvider theme={webLightTheme}>
<ConfigPage />
</FluentProvider>
</MemoryRouter>,
);
const jailsTab = screen.getByTestId("jails-tab");
expect(jailsTab).toBeInTheDocument();
expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd");
});
}); });

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { HistoryPage } from "../HistoryPage";
let lastQuery: Record<string, unknown> | null = null;
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
lastQuery = query;
return {
items: [],
total: 0,
page: 1,
loading: false,
error: null,
setPage: vi.fn(),
refresh: vi.fn(),
};
});
vi.mock("../hooks/useHistory", () => ({
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
}));
vi.mock("../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />,
}));
vi.mock("../api/config", () => ({
fetchMapColorThresholds: async () => ({
threshold_low: 10,
threshold_medium: 50,
threshold_high: 100,
}),
}));
describe("HistoryPage", () => {
it("renders DashboardFilterBar and applies origin+range filters", async () => {
const user = userEvent.setup();
render(
<FluentProvider theme={webLightTheme}>
<HistoryPage />
</FluentProvider>,
);
// Initial load should include the default query.
expect(lastQuery).toEqual({ page_size: 50 });
// Change the time-range and origin filter, then apply.
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
await user.click(screen.getByRole("button", { name: /Apply/i }));
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
});
});

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter } from "react-router-dom";
import { JailsPage } from "../JailsPage";
import type { JailSummary } from "../../types/jail";
const mockNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = (await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
)) as unknown as Record<string, unknown>;
return {
...actual,
useNavigate: () => mockNavigate,
};
});
vi.mock("../hooks/useJails", () => ({
useJails: () => ({
jails: [
{
name: "sshd",
enabled: true,
running: true,
idle: false,
backend: "systemd",
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: {
currently_banned: 1,
total_banned: 10,
currently_failed: 0,
total_failed: 0,
},
},
] as JailSummary[],
total: 1,
loading: false,
error: null,
refresh: vi.fn(),
startJail: vi.fn().mockResolvedValue(undefined),
stopJail: vi.fn().mockResolvedValue(undefined),
setIdle: vi.fn().mockResolvedValue(undefined),
reloadJail: vi.fn().mockResolvedValue(undefined),
reloadAll: vi.fn().mockResolvedValue(undefined),
}),
}));
function renderPage() {
return render(
<MemoryRouter>
<FluentProvider theme={webLightTheme}>
<JailsPage />
</FluentProvider>
</MemoryRouter>,
);
}
describe("JailsPage", () => {
it("navigates to Configuration → Jails when a jail is clicked", async () => {
renderPage();
const user = userEvent.setup();
await user.click(screen.getByText("sshd"));
expect(mockNavigate).toHaveBeenCalledWith("/config", {
state: { tab: "jails", jail: "sshd" },
});
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MapPage } from "../MapPage";
const mockFetchMapColorThresholds = vi.fn(async () => ({
threshold_low: 10,
threshold_medium: 50,
threshold_high: 100,
}));
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
const mockUseMapData = vi.fn((range: string, origin: string) => {
lastArgs = { range, origin };
return {
countries: {},
countryNames: {},
bans: [],
total: 0,
loading: false,
error: null,
refresh: vi.fn(),
};
});
vi.mock("../hooks/useMapData", () => ({
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
}));
vi.mock("../api/config", async () => ({
fetchMapColorThresholds: mockFetchMapColorThresholds,
}));
vi.mock("../components/WorldMap", () => ({
WorldMap: () => <div data-testid="world-map" />,
}));
describe("MapPage", () => {
it("renders DashboardFilterBar and updates data when filters change", async () => {
const user = userEvent.setup();
render(
<FluentProvider theme={webLightTheme}>
<MapPage />
</FluentProvider>,
);
// Initial load should call useMapData with default filters.
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
expect(lastArgs.range).toBe("7d");
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
expect(lastArgs.origin).toBe("blocklist");
});
});

View File

@@ -50,8 +50,11 @@ export interface IpDetailResponse {
} }
/** Query parameters supported by GET /api/history */ /** Query parameters supported by GET /api/history */
import type { BanOriginFilter } from "./ban";
export interface HistoryQuery { export interface HistoryQuery {
range?: TimeRange; range?: TimeRange;
origin?: BanOriginFilter;
jail?: string; jail?: string;
ip?: string; ip?: string;
page?: number; page?: number;