From bf82e38b6e8492d6aafcbca9bc225f7a6b33e43b Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 17 Mar 2026 11:31:46 +0100 Subject: [PATCH] Fix blocklist-import bantime, unify filter bar, and improve config navigation --- .../fail2ban/jail.d/blocklist-import.conf | 4 +- Docs/Tasks.md | 78 ++++++++++ backend/app/routers/history.py | 7 +- backend/app/services/config_service.py | 5 +- backend/app/services/history_service.py | 13 +- backend/app/utils/jail_config.py | 2 +- backend/tests/test_routers/test_history.py | 12 ++ .../test_services/test_config_service.py | 21 +++ backend/tests/test_utils/test_jail_config.py | 4 + frontend/src/api/history.ts | 1 + frontend/src/components/config/JailsTab.tsx | 17 ++- .../config/__tests__/JailsTab.test.tsx | 77 ++++++++++ frontend/src/pages/ConfigPage.tsx | 17 ++- frontend/src/pages/HistoryPage.tsx | 49 +++---- frontend/src/pages/JailsPage.tsx | 137 ++++++++++-------- frontend/src/pages/MapPage.tsx | 69 ++------- .../src/pages/__tests__/ConfigPage.test.tsx | 24 ++- .../src/pages/__tests__/HistoryPage.test.tsx | 58 ++++++++ .../src/pages/__tests__/JailsPage.test.tsx | 74 ++++++++++ frontend/src/pages/__tests__/MapPage.test.tsx | 58 ++++++++ frontend/src/types/history.ts | 3 + 21 files changed, 566 insertions(+), 164 deletions(-) create mode 100644 frontend/src/components/config/__tests__/JailsTab.test.tsx create mode 100644 frontend/src/pages/__tests__/HistoryPage.test.tsx create mode 100644 frontend/src/pages/__tests__/JailsPage.test.tsx create mode 100644 frontend/src/pages/__tests__/MapPage.test.tsx diff --git a/Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf b/Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf index 568619b..0bae8b3 100644 --- a/Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf +++ b/Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf @@ -18,8 +18,8 @@ logpath = /dev/null backend = auto maxretry = 1 findtime = 1d -# Block imported IPs for one week. -bantime = 1w +# Block imported IPs for 24 hours. +bantime = 86400 # Never ban the Docker bridge network or localhost. ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 diff --git a/Docs/Tasks.md b/Docs/Tasks.md index ade2917..c061a96 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -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 ` 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: "" }`. 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 ``. +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 backend ` 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 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 `` 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 `` 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 `` with ``. Add an `originFilter` state (`BanOriginFilter`, default `"all"`) and wire it through ``. 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. + +--- + diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index 1abeb0e..132d889 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from fastapi import APIRouter, HTTPException, Query, Request 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.services import geo_service, history_service @@ -52,6 +52,10 @@ async def get_history( default=None, 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_size: int = Query( default=_DEFAULT_PAGE_SIZE, @@ -89,6 +93,7 @@ async def get_history( range_=range, jail=jail, ip_filter=ip, + origin=origin, page=page, page_size=page_size, geo_enricher=_enricher, diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 4f5a3c4..5baa492 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -368,8 +368,9 @@ async def update_jail_config( await _set("datepattern", update.date_pattern) if update.dns_mode is not None: await _set("usedns", update.dns_mode) - if update.backend is not None: - await _set("backend", update.backend) + # Fail2ban does not support changing the log monitoring backend at runtime. + # 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: await _set("logencoding", update.log_encoding) if update.prefregex is not None: diff --git a/backend/app/services/history_service.py b/backend/app/services/history_service.py index 26c2f78..65cd844 100644 --- a/backend/app/services/history_service.py +++ b/backend/app/services/history_service.py @@ -16,7 +16,7 @@ from typing import Any import aiosqlite 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 ( HistoryBanItem, HistoryListResponse, @@ -58,6 +58,7 @@ async def list_history( *, range_: TimeRange | None = None, jail: str | None = None, + origin: BanOrigin | None = None, ip_filter: str | None = None, page: int = 1, page_size: int = _DEFAULT_PAGE_SIZE, @@ -73,6 +74,8 @@ async def list_history( socket_path: Path to the fail2ban Unix domain socket. range_: Time-range preset. ``None`` means all-time (no time filter). 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 (or a prefix — the query uses ``LIKE ip_filter%``). page: 1-based page number (default: ``1``). @@ -99,6 +102,14 @@ async def list_history( wheres.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: wheres.append("ip LIKE ?") params.append(f"{ip_filter}%") diff --git a/backend/app/utils/jail_config.py b/backend/app/utils/jail_config.py index c6eaf00..31cc38c 100644 --- a/backend/app/utils/jail_config.py +++ b/backend/app/utils/jail_config.py @@ -49,7 +49,7 @@ logpath = /dev/null backend = auto maxretry = 1 findtime = 1d -bantime = 1w +bantime = 86400 ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 """ diff --git a/backend/tests/test_routers/test_history.py b/backend/tests/test_routers/test_history.py index 898a3cf..314f9bb 100644 --- a/backend/tests/test_routers/test_history.py +++ b/backend/tests/test_routers/test_history.py @@ -213,6 +213,18 @@ class TestHistoryList: _args, kwargs = mock_fn.call_args 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: """An empty history returns items=[] and total=0.""" with patch( diff --git a/backend/tests/test_services/test_config_service.py b/backend/tests/test_services/test_config_service.py index 6b90074..9ba6e94 100644 --- a/backend/tests/test_services/test_config_service.py +++ b/backend/tests/test_services/test_config_service.py @@ -256,6 +256,27 @@ class TestUpdateJailConfig: assert "bantime" 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: """update_jail_config raises ConfigValidationError for invalid regex.""" from app.models.config import JailConfigUpdate diff --git a/backend/tests/test_utils/test_jail_config.py b/backend/tests/test_utils/test_jail_config.py index 35f55b6..1f87e63 100644 --- a/backend/tests/test_utils/test_jail_config.py +++ b/backend/tests/test_utils/test_jail_config.py @@ -65,6 +65,10 @@ class TestEnsureJailConfigs: content = _read(jail_d, conf_file) 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 for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL): content = _read(jail_d, local_file) diff --git a/frontend/src/api/history.ts b/frontend/src/api/history.ts index eadc34c..e318a5a 100644 --- a/frontend/src/api/history.ts +++ b/frontend/src/api/history.ts @@ -18,6 +18,7 @@ export async function fetchHistory( ): Promise { const params = new URLSearchParams(); 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.ip) params.set("ip", query.ip); if (query.page !== undefined) params.set("page", String(query.page)); diff --git a/frontend/src/components/config/JailsTab.tsx b/frontend/src/components/config/JailsTab.tsx index 9606325..30d75b7 100644 --- a/frontend/src/components/config/JailsTab.tsx +++ b/frontend/src/components/config/JailsTab.tsx @@ -216,7 +216,6 @@ function JailConfigDetail({ ignore_regex: ignoreRegex, date_pattern: datePattern !== "" ? datePattern : null, dns_mode: dnsMode, - backend, log_encoding: logEncoding, prefregex: prefRegex !== "" ? prefRegex : null, bantime_escalation: { @@ -231,7 +230,7 @@ function JailConfigDetail({ }), [ banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern, - dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor, + dnsMode, logEncoding, prefRegex, escEnabled, escFactor, escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails, jail.ban_time, jail.find_time, jail.max_retry, ], @@ -758,7 +757,12 @@ function InactiveJailDetail({ * * @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 { jails, loading, error, refresh, updateJail } = useJailConfigs(); @@ -819,6 +823,13 @@ export function JailsTab(): React.JSX.Element { return [...activeItems, ...inactiveItems]; }, [jails, inactiveJails]); + useEffect(() => { + if (!initialJail || selectedName) return; + if (listItems.some((item) => item.name === initialJail)) { + setSelectedName(initialJail); + } + }, [initialJail, listItems, selectedName]); + const activeJailMap = useMemo( () => new Map(jails.map((j) => [j.name, j])), [jails], diff --git a/frontend/src/components/config/__tests__/JailsTab.test.tsx b/frontend/src/components/config/__tests__/JailsTab.test.tsx new file mode 100644 index 0000000..0af5234 --- /dev/null +++ b/frontend/src/components/config/__tests__/JailsTab.test.tsx @@ -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> = []; + mockUseAutoSave.mockImplementation((value) => { + autoSavePayloads.push(value as Record); + 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( + + + , + ); + + expect(autoSavePayloads.length).toBeGreaterThan(0); + const lastPayload = autoSavePayloads[autoSavePayloads.length - 1]; + + expect(lastPayload).not.toHaveProperty("backend"); + }); +}); diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index b3bb7d0..b250c2c 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -13,7 +13,8 @@ * 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 { ActionsTab, @@ -58,8 +59,16 @@ type TabValue = export function ConfigPage(): React.JSX.Element { const styles = useStyles(); + const location = useLocation(); const [tab, setTab] = useState("jails"); + useEffect(() => { + const state = location.state as { tab?: string; jail?: string } | null; + if (state?.tab === "jails") { + setTab("jails"); + } + }, [location.state]); + return (
@@ -86,7 +95,11 @@ export function ConfigPage(): React.JSX.Element {
- {tab === "jails" && } + {tab === "jails" && ( + + )} {tab === "filters" && } {tab === "actions" && } {tab === "server" && } diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx index e017039..93be5cb 100644 --- a/frontend/src/pages/HistoryPage.tsx +++ b/frontend/src/pages/HistoryPage.tsx @@ -19,7 +19,6 @@ import { Input, MessageBar, MessageBarBody, - Select, Spinner, Table, TableBody, @@ -30,8 +29,6 @@ import { TableHeaderCell, TableRow, Text, - Toolbar, - ToolbarButton, createTableColumn, makeStyles, tokens, @@ -42,8 +39,10 @@ import { ChevronLeftRegular, ChevronRightRegular, } from "@fluentui/react-icons"; +import { DashboardFilterBar } from "../components/DashboardFilterBar"; import { useHistory, useIpHistory } from "../hooks/useHistory"; import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history"; +import type { BanOriginFilter } from "../types/ban"; // --------------------------------------------------------------------------- // Constants @@ -54,13 +53,6 @@ const HIGH_BAN_THRESHOLD = 5; 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 // --------------------------------------------------------------------------- @@ -381,7 +373,8 @@ export function HistoryPage(): React.JSX.Element { const styles = useStyles(); // Filter state - const [range, setRange] = useState(undefined); + const [range, setRange] = useState("24h"); + const [originFilter, setOriginFilter] = useState("all"); const [jailFilter, setJailFilter] = useState(""); const [ipFilter, setIpFilter] = useState(""); const [appliedQuery, setAppliedQuery] = useState({ @@ -397,11 +390,12 @@ export function HistoryPage(): React.JSX.Element { const applyFilters = useCallback((): void => { setAppliedQuery({ range: range, + origin: originFilter !== "all" ? originFilter : undefined, jail: jailFilter.trim() || undefined, ip: ipFilter.trim() || undefined, page_size: PAGE_SIZE, }); - }, [range, jailFilter, ipFilter]); + }, [range, originFilter, jailFilter, ipFilter]); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); @@ -452,24 +446,16 @@ export function HistoryPage(): React.JSX.Element { {/* Filter bar */} {/* ---------------------------------------------------------------- */}
-
- Time range - -
+ { + setRange(value); + }} + originFilter={originFilter} + onOriginFilterChange={(value) => { + setOriginFilter(value); + }} + />
Jail @@ -506,7 +492,8 @@ export function HistoryPage(): React.JSX.Element { appearance="subtle" size="small" onClick={(): void => { - setRange(undefined); + setRange("24h"); + setOriginFilter("all"); setJailFilter(""); setIpFilter(""); setAppliedQuery({ page_size: PAGE_SIZE }); diff --git a/frontend/src/pages/JailsPage.tsx b/frontend/src/pages/JailsPage.tsx index 11a9c91..dd7ac94 100644 --- a/frontend/src/pages/JailsPage.tsx +++ b/frontend/src/pages/JailsPage.tsx @@ -9,7 +9,7 @@ * geo-location details. */ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Badge, Button, @@ -42,7 +42,7 @@ import { SearchRegular, StopRegular, } from "@fluentui/react-icons"; -import { Link } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails"; import type { JailSummary } from "../types/jail"; import { ApiError } from "../api/client"; @@ -151,77 +151,88 @@ function fmtSeconds(s: number): string { return `${String(Math.round(s / 3600))}h`; } -// --------------------------------------------------------------------------- -// Jail overview columns -// --------------------------------------------------------------------------- - -const jailColumns: TableColumnDefinition[] = [ - createTableColumn({ - columnId: "name", - renderHeaderCell: () => "Jail", - renderCell: (j) => ( - - - {j.name} - - - ), - }), - createTableColumn({ - columnId: "status", - renderHeaderCell: () => "Status", - renderCell: (j) => { - if (!j.running) return stopped; - if (j.idle) return idle; - return running; - }, - }), - createTableColumn({ - columnId: "backend", - renderHeaderCell: () => "Backend", - renderCell: (j) => {j.backend}, - }), - createTableColumn({ - columnId: "banned", - renderHeaderCell: () => "Banned", - renderCell: (j) => ( - {j.status ? String(j.status.currently_banned) : "—"} - ), - }), - createTableColumn({ - columnId: "failed", - renderHeaderCell: () => "Failed", - renderCell: (j) => ( - {j.status ? String(j.status.currently_failed) : "—"} - ), - }), - createTableColumn({ - columnId: "findTime", - renderHeaderCell: () => "Find Time", - renderCell: (j) => {fmtSeconds(j.find_time)}, - }), - createTableColumn({ - columnId: "banTime", - renderHeaderCell: () => "Ban Time", - renderCell: (j) => {fmtSeconds(j.ban_time)}, - }), - createTableColumn({ - columnId: "maxRetry", - renderHeaderCell: () => "Max Retry", - renderCell: (j) => {String(j.max_retry)}, - }), -]; - // --------------------------------------------------------------------------- // Sub-component: Jail overview section // --------------------------------------------------------------------------- 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(null); + const jailColumns = useMemo[]>( + () => [ + createTableColumn({ + columnId: "name", + renderHeaderCell: () => "Jail", + renderCell: (j) => ( + + ), + }), + createTableColumn({ + columnId: "status", + renderHeaderCell: () => "Status", + renderCell: (j) => { + if (!j.running) return stopped; + if (j.idle) return idle; + return running; + }, + }), + createTableColumn({ + columnId: "backend", + renderHeaderCell: () => "Backend", + renderCell: (j) => {j.backend}, + }), + createTableColumn({ + columnId: "banned", + renderHeaderCell: () => "Banned", + renderCell: (j) => ( + {j.status ? String(j.status.currently_banned) : "—"} + ), + }), + createTableColumn({ + columnId: "failed", + renderHeaderCell: () => "Failed", + renderCell: (j) => ( + {j.status ? String(j.status.currently_failed) : "—"} + ), + }), + createTableColumn({ + columnId: "findTime", + renderHeaderCell: () => "Find Time", + renderCell: (j) => {fmtSeconds(j.find_time)}, + }), + createTableColumn({ + columnId: "banTime", + renderHeaderCell: () => "Ban Time", + renderCell: (j) => {fmtSeconds(j.ban_time)}, + }), + createTableColumn({ + columnId: "maxRetry", + renderHeaderCell: () => "Max Retry", + renderCell: (j) => {String(j.max_retry)}, + }), + ], + [navigate], + ); + const handle = (fn: () => Promise): void => { setOpError(null); fn().catch((err: unknown) => { diff --git a/frontend/src/pages/MapPage.tsx b/frontend/src/pages/MapPage.tsx index df59c88..79a7bba 100644 --- a/frontend/src/pages/MapPage.tsx +++ b/frontend/src/pages/MapPage.tsx @@ -12,7 +12,6 @@ import { Button, MessageBar, MessageBarBody, - Select, Spinner, Table, TableBody, @@ -22,19 +21,16 @@ import { TableHeaderCell, TableRow, Text, - Toolbar, - ToolbarButton, - Tooltip, makeStyles, tokens, } from "@fluentui/react-components"; import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons"; +import { DashboardFilterBar } from "../components/DashboardFilterBar"; 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 @@ -56,15 +52,6 @@ const useStyles = makeStyles({ flexWrap: "wrap", 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: { overflow: "auto", 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 // --------------------------------------------------------------------------- @@ -136,41 +112,20 @@ export function MapPage(): React.JSX.Element { World Map - - - - {/* Origin filter */} - - - +
{/* ---------------------------------------------------------------- */} diff --git a/frontend/src/pages/__tests__/ConfigPage.test.tsx b/frontend/src/pages/__tests__/ConfigPage.test.tsx index 0710f05..7f7173c 100644 --- a/frontend/src/pages/__tests__/ConfigPage.test.tsx +++ b/frontend/src/pages/__tests__/ConfigPage.test.tsx @@ -6,7 +6,11 @@ import { ConfigPage } from "../ConfigPage"; // Mock all tab components to avoid deep render trees and API calls. vi.mock("../../components/config", () => ({ - JailsTab: () =>
JailsTab
, + JailsTab: ({ initialJail }: { initialJail?: string }) => ( +
+ JailsTab +
+ ), FiltersTab: () =>
FiltersTab
, ActionsTab: () =>
ActionsTab
, ServerTab: () =>
ServerTab
, @@ -53,4 +57,22 @@ describe("ConfigPage", () => { renderPage(); expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument(); }); + + it("selects the Jails tab based on location state", () => { + render( + + + + + , + ); + + const jailsTab = screen.getByTestId("jails-tab"); + expect(jailsTab).toBeInTheDocument(); + expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd"); + }); }); diff --git a/frontend/src/pages/__tests__/HistoryPage.test.tsx b/frontend/src/pages/__tests__/HistoryPage.test.tsx new file mode 100644 index 0000000..88b4302 --- /dev/null +++ b/frontend/src/pages/__tests__/HistoryPage.test.tsx @@ -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 | null = null; +const mockUseHistory = vi.fn((query: Record) => { + 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) => mockUseHistory(query), + useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }), +})); + +vi.mock("../components/WorldMap", () => ({ + WorldMap: () =>
, +})); + +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( + + + , + ); + + // 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" }); + }); +}); diff --git a/frontend/src/pages/__tests__/JailsPage.test.tsx b/frontend/src/pages/__tests__/JailsPage.test.tsx new file mode 100644 index 0000000..e892c1f --- /dev/null +++ b/frontend/src/pages/__tests__/JailsPage.test.tsx @@ -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( + "react-router-dom", + )) as unknown as Record; + 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( + + + + + , + ); +} + +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" }, + }); + }); +}); diff --git a/frontend/src/pages/__tests__/MapPage.test.tsx b/frontend/src/pages/__tests__/MapPage.test.tsx new file mode 100644 index 0000000..8b1f668 --- /dev/null +++ b/frontend/src/pages/__tests__/MapPage.test.tsx @@ -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: () =>
, +})); + +describe("MapPage", () => { + it("renders DashboardFilterBar and updates data when filters change", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + // 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"); + }); +}); diff --git a/frontend/src/types/history.ts b/frontend/src/types/history.ts index 5a22211..209e2be 100644 --- a/frontend/src/types/history.ts +++ b/frontend/src/types/history.ts @@ -50,8 +50,11 @@ export interface IpDetailResponse { } /** Query parameters supported by GET /api/history */ +import type { BanOriginFilter } from "./ban"; + export interface HistoryQuery { range?: TimeRange; + origin?: BanOriginFilter; jail?: string; ip?: string; page?: number;