Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f75db75f | |||
| 554c75247f | |||
| 6e2abe9d97 | |||
| 15d53a8e96 | |||
| acdb0e1f03 | |||
| f1e3d4c4c9 | |||
| c51858ec71 | |||
| c03a5c1cbc | |||
| eb983799cd | |||
| d3f564d66f | |||
| bbd57c808b | |||
| ffaa14f864 |
@@ -1 +1 @@
|
||||
v0.9.16
|
||||
v0.9.19
|
||||
|
||||
@@ -78,6 +78,11 @@ Chains steps 1–3 automatically with appropriate sleep intervals.
|
||||
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
||||
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
||||
|
||||
BanGUI also extends fail2ban history retention for archive backfill. In
|
||||
the development config `fail2ban/fail2ban.conf` the database purge age is
|
||||
set to `648000` seconds (7.5 days) so the first archive sync can recover a
|
||||
full 7-day window before fail2ban purges old rows.
|
||||
|
||||
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
|
||||
|
||||
```ini
|
||||
|
||||
@@ -52,6 +52,8 @@ The main landing page after login. Shows recent ban activity at a glance.
|
||||
- Last 7 days (week)
|
||||
- Last 30 days (month)
|
||||
- Last 365 days (year)
|
||||
- **Data source selection:** The "Last 24 hours" preset queries fail2ban's live database directly for real-time accuracy. All longer presets (7 days, 30 days, 365 days) query the BanGUI long-term archive, because fail2ban's own database only retains the last 24 hours by default.
|
||||
- A **data-source badge** next to the time-range selector indicates whether the current view is showing **Live (fail2ban DB)** or **Archive (BanGUI DB)** data.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,14 +72,21 @@ A geographical overview of ban activity.
|
||||
- Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend)
|
||||
- The color threshold values are configurable through the application settings
|
||||
- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner.
|
||||
- For every country that has bans, the total count is displayed centred inside that country's borders in the selected time range.
|
||||
- Countries with zero banned IPs show no number and no label — they remain blank and transparent.
|
||||
- Clicking a country filters the companion table below to show only bans from that country.
|
||||
- For every country that has bans, the total count is shown only in the country tooltip, not rendered on the map itself.
|
||||
- Countries with zero banned IPs show no tooltip and remain blank and transparent.
|
||||
- Clicking a country filters the companion table below to show only bans from that country. When a country is selected the server returns the **complete** list of bans for that country in the chosen time window — the default 200-row companion cap is lifted for filtered queries. Clicking the same country again or using the "Clear filter" button reverts to the standard unfiltered view.
|
||||
- Time-range selector with the same quick presets:
|
||||
- Last 24 hours
|
||||
- Last 7 days
|
||||
- Last 30 days
|
||||
- Last 365 days
|
||||
- **Data source selection:** Same rule as the Dashboard — "Last 24 hours" uses the live fail2ban database; all other ranges use the BanGUI archive.
|
||||
- A **data-source badge** is displayed alongside the time-range selector indicating **Live (fail2ban DB)** or **Archive (BanGUI DB)**.
|
||||
|
||||
### Companion Table
|
||||
|
||||
- The column header row is always visible at the top of the scrollable table area (sticky positioning) so column labels remain readable regardless of scroll position.
|
||||
- The pagination / page-size bar is always visible at the bottom of the scrollable table area (sticky positioning) so the user can navigate pages without scrolling back down.
|
||||
|
||||
---
|
||||
|
||||
@@ -245,13 +254,15 @@ A page to inspect and modify the fail2ban configuration without leaving the web
|
||||
|
||||
## 7. Ban History
|
||||
|
||||
A view for exploring historical ban data stored in the fail2ban database.
|
||||
A view for exploring historical ban data stored in the BanGUI long-term archive.
|
||||
|
||||
### History Table
|
||||
|
||||
- Browse all past bans across all jails, not just the currently active ones.
|
||||
- **Columns:** Time of ban, IP address, jail, ban duration, ban count (how many times this IP was banned), country.
|
||||
- Filter by jail, by IP address, or by time range.
|
||||
- The default time range on first load is **Last 7 days** and the data source is always the **BanGUI archive**, ensuring the full retention window is visible regardless of fail2ban's `dbpurgeage` setting.
|
||||
- A **data-source badge** is displayed indicating **Archive (BanGUI DB)**.
|
||||
- See at a glance which IPs are repeat offenders (high ban count).
|
||||
|
||||
### Per-IP History
|
||||
@@ -265,7 +276,7 @@ A view for exploring historical ban data stored in the fail2ban database.
|
||||
- On each configured sync cycle (default every 5 minutes), BanGUI reads latest entries from fail2ban `bans` table and appends any new events to BanGUI history storage.
|
||||
- Supports both `ban` and `unban` events; audit record includes: `timestamp`, `ip`, `jail`, `action`, `duration`, `origin` (manual, auto, blocklist, etc.), `failures`, `matches`, and optional `country` / `ASN` enrichment.
|
||||
- Includes incremental import logic with dedupe: using unique constraint on (ip, jail, action, timeofban) to prevent duplication across sync cycles.
|
||||
- Provides backfill mode for initial startup: import last N days (configurable, default 7 days) of existing fail2ban history into BanGUI to avoid dark gaps after restart.
|
||||
- Provides backfill mode for initial startup: import the last 7.5 days of existing fail2ban history into BanGUI to avoid dark gaps after restart. Requires fail2ban's `dbpurgeage` to be set to at least `648000` (7.5 days) — BanGUI ships with this value pre-configured in its Docker setup.
|
||||
- Includes configurable archive purge policy in BanGUI (default 365 days), separate from fail2ban `dbpurgeage`, to keep app storage bounded while preserving audit data.
|
||||
- Expose API endpoints for querying persistent history, with filters for timeframe, jail, origin, IP, and current ban status.
|
||||
- On fail2ban connectivity failure, BanGUI continues serving historical data; next successful sync resumes ingestion without data loss.
|
||||
|
||||
174
Docs/Tasks.md
174
Docs/Tasks.md
@@ -8,128 +8,66 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
||||
|
||||
## Open Issues
|
||||
|
||||
### Replace `react-simple-maps` with `d3-geo` in WorldMap
|
||||
|
||||
The current `WorldMap` component (`frontend/src/components/WorldMap.tsx`) uses the `react-simple-maps` library (`ComposableMap`, `ZoomableGroup`, `Geography`, `useGeographies`). This library wraps d3-geo but adds a heavy abstraction layer and fetches the TopoJSON geography file from a remote CDN at runtime. Replace it with direct d3-geo rendering, following the pattern demonstrated in the reference project at `/media/lukas/Volume/repo/worldmaptest/`.
|
||||
|
||||
Reference: `Docs/Features.md` §4 (World Map View) for the full feature specification.
|
||||
|
||||
**All existing features must be preserved.** The component's public API (`WorldMapProps`) and behaviour must remain identical so that `MapPage.tsx`, `HistoryPage.tsx`, and the existing unit test continue to work after the migration.
|
||||
|
||||
---
|
||||
|
||||
#### Task 1 — Swap npm dependencies [DONE]
|
||||
### TASK-001 — WorldMap: filter companion table by selected country (server-side)
|
||||
|
||||
Remove `react-simple-maps` and `@types/react-simple-maps` from `frontend/package.json`. Add the following packages that the new implementation requires:
|
||||
**Status:** Done
|
||||
**Priority:** Medium
|
||||
**Domain:** Full-stack (backend + frontend)
|
||||
**References:** `Docs/Features.md §4`, `Docs/Web-Development.md`
|
||||
|
||||
- `d3-geo` — geographic projection and SVG path generation.
|
||||
- `@types/d3-geo` — TypeScript definitions for d3-geo.
|
||||
- `topojson-client` — converts TopoJSON to GeoJSON `FeatureCollection`.
|
||||
- `@types/topojson-client` — TypeScript definitions for topojson-client.
|
||||
- `world-atlas` — provides the `countries-110m.json` TopoJSON file as a local npm asset (no more CDN fetch at runtime).
|
||||
#### Background
|
||||
|
||||
Run `npm install` and verify the lock file updates cleanly.
|
||||
The `GET /api/dashboard/bans/by-country` endpoint always returns the **200 most recent** ban rows in `bans` (constant `_MAX_COMPANION_BANS = 200` in `backend/app/services/ban_service.py`). `MapPage.tsx` stores a `selectedCountry` state and filters the returned rows client-side via `visibleBans`. This means the companion table can only show the fraction of a country's bans that fall within the global top-200 window. If the selected time range has, say, 1 500 bans and 300 are from China, but China's bans are not all in the top 200 overall, the table will silently display fewer than 300 rows.
|
||||
|
||||
When a country is selected the companion table **must** return the complete set of bans for that country so the user sees an accurate picture.
|
||||
|
||||
#### Desired behaviour
|
||||
|
||||
- No country selected → companion table shows the 200 most recent bans across all countries (existing behaviour, no change).
|
||||
- Country selected → the server returns **all** ban entries for that country in the selected time window; no client-side row-count cap applies.
|
||||
- Deselecting a country (clicking the same country again, or the "Clear filter" button) reverts to the default 200-row unfiltered view.
|
||||
- The existing `visibleBans` client-side filter in `MapPage.tsx` can remain as a defensive guard but must not be the only filter.
|
||||
|
||||
#### Implementation steps
|
||||
|
||||
1. **Backend — router** (`backend/app/routers/dashboard.py`)
|
||||
- Add `country_code: str | None = Query(default=None, description="ISO alpha-2 country code to filter companion rows.")` to `get_bans_by_country`.
|
||||
- Pass it to `ban_service.bans_by_country(..., country_code=country_code)`.
|
||||
|
||||
2. **Backend — service** (`backend/app/services/ban_service.py`)
|
||||
- Add `country_code: str | None = None` keyword argument to `bans_by_country`.
|
||||
- After `geo_map` is built (existing geo-resolution step), collect IPs whose resolved country matches `country_code`.
|
||||
- For the **fail2ban source**: call `fail2ban_db_repo.get_currently_banned` with `ip_filter=matched_ips` and no `limit` (remove the `_MAX_COMPANION_BANS` cap for filtered queries).
|
||||
- For the **archive source**: filter `all_rows` to those whose IP is in `matched_ips` and return all of them (skip the `page_size=_MAX_COMPANION_BANS` call).
|
||||
- When `country_code` is `None`, behaviour is identical to today.
|
||||
|
||||
3. **Backend — repository** (`backend/app/repositories/fail2ban_db_repo.py`)
|
||||
- Add `ip_filter: list[str] | None = None` to `get_currently_banned`.
|
||||
- When provided and non-empty, append `AND ip IN ({placeholders})` to the SQL `WHERE` clause, parameterised safely (never interpolated as a string).
|
||||
|
||||
4. **Backend — repository (archive)** (`backend/app/repositories/history_archive_repo.py`)
|
||||
- Similarly add optional `ip_filter` to the archive companion-rows query used from `bans_by_country`.
|
||||
|
||||
5. **Frontend — API client** (`frontend/src/api/map.ts`)
|
||||
- Add optional `countryCode?: string` parameter to `fetchBansByCountry`.
|
||||
- When set, append `country_code=<value>` to the query string.
|
||||
|
||||
6. **Frontend — hook** (`frontend/src/hooks/useMapData.ts`)
|
||||
- Add `countryCode?: string` to the function signature.
|
||||
- Include it in the `useCallback` dependency array and pass it to `fetchBansByCountry`.
|
||||
|
||||
7. **Frontend — page** (`frontend/src/pages/MapPage.tsx`)
|
||||
- Pass `selectedCountry ?? undefined` as `countryCode` to `useMapData`.
|
||||
- The hook's effect will re-fetch automatically when `selectedCountry` changes; the existing `useEffect` that resets `page` to 1 already covers this.
|
||||
|
||||
#### Testing guidance
|
||||
|
||||
- Select a country that has > 200 bans in the chosen time window; confirm the companion table shows more than the previous cap would allow.
|
||||
- With no country selected, confirm only 200 rows are returned (no regression).
|
||||
- Deselect the country; confirm the unfiltered 200-row view is restored.
|
||||
- Test with the archive source as well as the fail2ban live source.
|
||||
- Verify the `ip_filter` SQL clause is parameterised and cannot be injected.
|
||||
|
||||
---
|
||||
|
||||
#### Task 2 — Rewrite `WorldMap.tsx` to use d3-geo directly [DONE]
|
||||
|
||||
Rewrite the component so that it renders a plain `<svg>` with `<path>` elements generated by d3-geo instead of the react-simple-maps wrappers. The implementation should follow this approach (as seen in the reference project):
|
||||
|
||||
1. **Import the TopoJSON locally** — `import worldData from "world-atlas/countries-110m.json"` instead of fetching from a CDN URL. Use `topojson-client`'s `feature()` to extract the GeoJSON `FeatureCollection` once (memoised).
|
||||
|
||||
2. **Create a projection** — Use `geoMercator()` from d3-geo (matching the current Mercator projection) with `.fitSize([width, height], featureCollection)` to auto-scale. Memoise the projection so it is only recomputed when the geometry changes.
|
||||
|
||||
3. **Create a path generator** — `geoPath().projection(projection)`. Memoise.
|
||||
|
||||
4. **Render countries** — Map over the GeoJSON features and render a `<path>` element for each country. Use the `ISO_NUMERIC_TO_ALPHA2` lookup (already exists in `frontend/src/data/isoNumericToAlpha2.ts`) to translate the numeric feature id to the alpha-2 code expected by the `countries` prop.
|
||||
|
||||
5. **Preserve colour coding** — Continue using `getBanCountColor()` from `frontend/src/utils/mapColors.ts` to compute each country's fill colour based on its ban count and the three threshold props.
|
||||
|
||||
6. **Preserve ban-count labels** — For every country with `count > 0`, compute the centroid with `pathGenerator.centroid(feature)` and render a `<text>` element at that position showing the count. Countries with zero bans must remain blank and transparent (no fill, no label).
|
||||
|
||||
7. **Preserve country selection** — Clicking a country calls `onSelectCountry` with the alpha-2 code (or `null` to deselect). The selected country must receive a distinct brand fill colour, matching the current behaviour.
|
||||
|
||||
8. **Preserve hover tooltip** — On `mouseenter` / `mousemove` / `mouseleave`, show/hide a tooltip portal (`createPortal` into `document.body`) displaying the country name and ban count. Use the same Fluent UI styled tooltip div that the current implementation uses.
|
||||
|
||||
9. **Preserve keyboard accessibility** — Each country with a known alpha-2 code must have `role="button"`, `tabIndex={0}`, an `aria-label` (`"CC: N ban(s)"`), and `aria-pressed` when selected. `Enter` and `Space` must trigger selection/deselection.
|
||||
|
||||
10. **Use a `viewBox`-based responsive SVG** — Set `viewBox="0 0 {width} {height}"` and `style={{ width: "100%", height: "auto" }}` so the map scales with its container, matching the reference project's approach.
|
||||
|
||||
---
|
||||
|
||||
#### Task 3 — Implement zoom and pan without `react-simple-maps` [DONE]
|
||||
|
||||
The current implementation relies on `ZoomableGroup` from react-simple-maps for zoom/pan. Reimplement this using a `<g>` wrapper with an SVG `transform` attribute driven by React state:
|
||||
|
||||
1. **State:** Track `zoom` (number, 1–8) and `center` (translate offset `[x, y]`).
|
||||
|
||||
2. **Zoom controls:** Keep the three overlay buttons (Zoom In `+`, Zoom Out `−`, Reset `⟲`) in the top-right corner. Each button adjusts the `zoom` state by ±0.5, clamped to `[1, 8]`. Reset sets zoom to 1 and center to `[0, 0]`.
|
||||
|
||||
3. **Mouse-wheel zoom:** Attach a `wheel` event handler to the SVG that increments/decrements zoom on scroll, zooming toward the cursor position.
|
||||
|
||||
4. **Click-and-drag pan:** Track `mousedown` → `mousemove` → `mouseup` on the SVG to translate the `center` offset. Only pan when the drag exceeds a small threshold (e.g. 3 px) to avoid conflicting with country click events.
|
||||
|
||||
5. **Touch support (stretch goal):** Optionally support pinch-to-zoom and touch-drag for tablet users.
|
||||
|
||||
6. **Apply transform:** Wrap all `<path>` and `<text>` elements in a `<g transform="translate(tx, ty) scale(zoom)">` group. Alternatively, use `d3-zoom` if a more robust implementation is preferred, but keep React as the rendering layer (no d3 DOM manipulation).
|
||||
|
||||
---
|
||||
|
||||
#### Task 4 — Update hover and selection styles to use CSS transitions [DONE]
|
||||
|
||||
The reference project applies hover highlights via CSS classes (`.country`, `.country.hovered`) with CSS `transition` instead of the react-simple-maps `style={{ default, hover, pressed }}` object. Adopt the same approach:
|
||||
|
||||
- Define CSS classes (or Fluent UI `makeStyles` rules) for default, hovered, and selected states.
|
||||
- Apply the correct class based on component state (`isSelected`, `isHovered`).
|
||||
- Use a CSS `transition` on `fill` and `stroke` for a smooth 150 ms highlight effect.
|
||||
- This avoids the react-simple-maps per-geography style object entirely.
|
||||
|
||||
Ensure the selected state still uses `tokens.colorBrandBackground` / `tokens.colorBrandBackgroundHover` / `tokens.colorBrandBackgroundPressed` from Fluent UI so the map integrates visually with the rest of the application.
|
||||
|
||||
---
|
||||
|
||||
#### Task 5 — Update the WorldMap unit test [DONE]
|
||||
|
||||
The existing test at `frontend/src/components/__tests__/WorldMap.test.tsx` mocks `react-simple-maps`. After the migration those mocks are invalid. Update the test:
|
||||
|
||||
1. **Remove the `vi.mock("react-simple-maps", ...)` block.**
|
||||
|
||||
2. **Mock the TopoJSON data instead.** Since the new implementation imports `world-atlas/countries-110m.json` directly, mock that module to return a minimal TopoJSON object containing a single country feature (e.g. id `"840"` for the US). Use `topojson-client`'s `feature()` to verify the mock produces a valid GeoJSON feature.
|
||||
|
||||
3. **Keep the same assertions:** tooltip appears on hover with country name and ban count, tooltip disappears on mouse leave, country element has correct ARIA attributes (`role="button"`, `aria-label`, `aria-pressed`).
|
||||
|
||||
4. **Verify zoom controls render:** assert that the three zoom buttons (Zoom In, Zoom Out, Reset) are present and have the correct `aria-label` values.
|
||||
|
||||
5. Also verify that tests in `MapPage.test.tsx` and `HistoryPage.test.tsx` still pass (they mock `WorldMap` at the component level so they should be unaffected, but confirm).
|
||||
|
||||
---
|
||||
|
||||
#### Task 6 — Remove CDN dependency and verify offline capability [DONE]
|
||||
|
||||
The old implementation fetched geography data from `https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json` at runtime. The new implementation bundles the data via the `world-atlas` npm package, so:
|
||||
|
||||
1. Delete the `GEO_URL` constant.
|
||||
2. Confirm the TopoJSON file is included in the Vite bundle (imported as a JSON module).
|
||||
3. Verify the map renders correctly without any network request for geography data (check the browser network tab or write a test that asserts no fetch calls are made for the old CDN URL).
|
||||
|
||||
---
|
||||
|
||||
#### Task 7 — Final integration smoke test [DONE]
|
||||
|
||||
After all changes, manually verify the following against the feature specification in `Docs/Features.md` §4:
|
||||
|
||||
- Countries are colour-coded by ban count (transparent → green → yellow → red) using smooth interpolation.
|
||||
- Ban count numbers are displayed centred inside each country that has bans.
|
||||
- Countries with zero bans are transparent with no label.
|
||||
- Clicking a country filters the companion ban table below.
|
||||
- Clicking the same country again deselects it.
|
||||
- Zoom in / zoom out / reset buttons work correctly (range 1×–8×).
|
||||
- Mouse-wheel zoom and click-drag pan work.
|
||||
- Tooltip appears on hover showing country name and localised ban count.
|
||||
- Keyboard navigation works (Tab to focus, Enter/Space to toggle selection).
|
||||
- The map is responsive and scales with the container width.
|
||||
- Time-range selector on `MapPage` still updates the map data correctly.
|
||||
- Colour thresholds from settings are applied (thresholdLow, thresholdMedium, thresholdHigh props).
|
||||
- Run `npm run test` — all existing tests pass.
|
||||
- Run `npm run build` — production build succeeds with no errors or warnings.
|
||||
|
||||
@@ -126,6 +126,7 @@ async def get_currently_banned(
|
||||
since: int,
|
||||
origin: BanOrigin | None = None,
|
||||
*,
|
||||
ip_filter: list[str] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> tuple[list[BanRecord], int]:
|
||||
@@ -135,6 +136,7 @@ async def get_currently_banned(
|
||||
db_path: File path to the fail2ban SQLite database.
|
||||
since: Unix timestamp to filter bans newer than or equal to.
|
||||
origin: Optional origin filter.
|
||||
ip_filter: Optional list of IP addresses to restrict the result to.
|
||||
limit: Optional maximum number of rows to return.
|
||||
offset: Optional offset for pagination.
|
||||
|
||||
@@ -142,14 +144,21 @@ async def get_currently_banned(
|
||||
A ``(records, total)`` tuple.
|
||||
"""
|
||||
|
||||
if ip_filter is not None and len(ip_filter) == 0:
|
||||
return [], 0
|
||||
|
||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||
ip_filter_clause = ""
|
||||
if ip_filter is not None:
|
||||
placeholder = ", ".join("?" for _ in ip_filter)
|
||||
ip_filter_clause = f" AND ip IN ({placeholder})"
|
||||
|
||||
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
|
||||
(since, *origin_params),
|
||||
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause + ip_filter_clause,
|
||||
(since, *origin_params, *(ip_filter or [])),
|
||||
) as cur:
|
||||
count_row = await cur.fetchone()
|
||||
total: int = int(count_row[0]) if count_row else 0
|
||||
@@ -157,9 +166,9 @@ async def get_currently_banned(
|
||||
query = (
|
||||
"SELECT jail, ip, timeofban, bancount, data "
|
||||
"FROM bans "
|
||||
"WHERE timeofban >= ?" + origin_clause + " ORDER BY timeofban DESC"
|
||||
"WHERE timeofban >= ?" + origin_clause + ip_filter_clause + " ORDER BY timeofban DESC"
|
||||
)
|
||||
params: list[object] = [since, *origin_params]
|
||||
params: list[object] = [since, *origin_params, *(ip_filter or [])]
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
@@ -40,13 +40,16 @@ async def get_archived_history(
|
||||
db: aiosqlite.Connection,
|
||||
since: int | None = None,
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
ip_filter: str | list[str] | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
action: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 100,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Return a paginated archived history result set."""
|
||||
if isinstance(ip_filter, list) and len(ip_filter) == 0:
|
||||
return [], 0
|
||||
|
||||
wheres: list[str] = []
|
||||
params: list[object] = []
|
||||
|
||||
@@ -59,8 +62,13 @@ async def get_archived_history(
|
||||
params.append(jail)
|
||||
|
||||
if ip_filter is not None:
|
||||
wheres.append("ip LIKE ?")
|
||||
params.append(f"{ip_filter}%")
|
||||
if isinstance(ip_filter, list):
|
||||
placeholder = ", ".join("?" for _ in ip_filter)
|
||||
wheres.append(f"ip IN ({placeholder})")
|
||||
params.extend(ip_filter)
|
||||
else:
|
||||
wheres.append("ip LIKE ?")
|
||||
params.append(f"{ip_filter}%")
|
||||
|
||||
if origin == "blocklist":
|
||||
wheres.append("jail = ?")
|
||||
@@ -108,7 +116,7 @@ async def get_all_archived_history(
|
||||
db: aiosqlite.Connection,
|
||||
since: int | None = None,
|
||||
jail: str | None = None,
|
||||
ip_filter: str | None = None,
|
||||
ip_filter: str | list[str] | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
action: str | None = None,
|
||||
) -> list[dict]:
|
||||
|
||||
@@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
@@ -83,7 +83,10 @@ async def get_dashboard_bans(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
|
||||
source: Literal["fail2ban", "archive"] = Query(
|
||||
default="fail2ban",
|
||||
description="Data source: 'fail2ban' or 'archive'.",
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="1-based page number."),
|
||||
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
|
||||
origin: BanOrigin | None = Query(
|
||||
@@ -137,11 +140,18 @@ async def get_bans_by_country(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
|
||||
source: Literal["fail2ban", "archive"] = Query(
|
||||
default="fail2ban",
|
||||
description="Data source: 'fail2ban' or 'archive'.",
|
||||
),
|
||||
origin: BanOrigin | None = Query(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
),
|
||||
country_code: str | None = Query(
|
||||
default=None,
|
||||
description="ISO alpha-2 country code to filter companion rows.",
|
||||
),
|
||||
) -> BansByCountryResponse:
|
||||
"""Return ban counts aggregated by ISO country code.
|
||||
|
||||
@@ -173,6 +183,7 @@ async def get_bans_by_country(
|
||||
geo_batch_lookup=geo_service.lookup_batch,
|
||||
app_db=request.app.state.db,
|
||||
origin=origin,
|
||||
country_code=country_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -185,7 +196,10 @@ async def get_ban_trend(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
|
||||
source: Literal["fail2ban", "archive"] = Query(
|
||||
default="fail2ban",
|
||||
description="Data source: 'fail2ban' or 'archive'.",
|
||||
),
|
||||
origin: BanOrigin | None = Query(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
@@ -235,7 +249,10 @@ async def get_bans_by_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
||||
source: str = Query(default="fail2ban", description="Data source: 'fail2ban' or 'archive'."),
|
||||
source: Literal["fail2ban", "archive"] = Query(
|
||||
default="fail2ban",
|
||||
description="Data source: 'fail2ban' or 'archive'.",
|
||||
),
|
||||
origin: BanOrigin | None = Query(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
|
||||
@@ -15,7 +15,7 @@ Routes
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
@@ -56,7 +56,7 @@ async def get_history(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
),
|
||||
source: str = Query(
|
||||
source: Literal["fail2ban", "archive"] = Query(
|
||||
default="fail2ban",
|
||||
description="Data source: 'fail2ban' or 'archive'.",
|
||||
),
|
||||
|
||||
@@ -290,6 +290,7 @@ async def bans_by_country(
|
||||
geo_enricher: GeoEnricher | None = None,
|
||||
app_db: aiosqlite.Connection | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
country_code: str | None = None,
|
||||
) -> BansByCountryResponse:
|
||||
"""Aggregate ban counts per country for the selected time window.
|
||||
|
||||
@@ -350,16 +351,6 @@ async def bans_by_country(
|
||||
|
||||
total = len(all_rows)
|
||||
|
||||
# companion rows for the table should be most recent
|
||||
companion_rows, _ = await get_archived_history(
|
||||
db=app_db,
|
||||
since=since,
|
||||
origin=origin,
|
||||
action="ban",
|
||||
page=1,
|
||||
page_size=_MAX_COMPANION_BANS,
|
||||
)
|
||||
|
||||
agg_rows = {}
|
||||
for row in all_rows:
|
||||
ip = str(row["ip"])
|
||||
@@ -393,14 +384,6 @@ async def bans_by_country(
|
||||
origin=origin,
|
||||
)
|
||||
|
||||
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
limit=_MAX_COMPANION_BANS,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
unique_ips = [r.ip for r in agg_rows]
|
||||
geo_map: dict[str, GeoInfo] = {}
|
||||
|
||||
@@ -434,6 +417,54 @@ async def bans_by_country(
|
||||
results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips))
|
||||
geo_map = {ip: geo for ip, geo in results if geo is not None}
|
||||
|
||||
companion_rows: list[dict[str, object] | fail2ban_db_repo.BanRecord]
|
||||
if country_code is None:
|
||||
if source == "archive":
|
||||
companion_rows, _ = await get_archived_history(
|
||||
db=app_db,
|
||||
since=since,
|
||||
origin=origin,
|
||||
action="ban",
|
||||
page=1,
|
||||
page_size=_MAX_COMPANION_BANS,
|
||||
)
|
||||
else:
|
||||
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
limit=_MAX_COMPANION_BANS,
|
||||
offset=0,
|
||||
)
|
||||
else:
|
||||
matched_ips = [
|
||||
ip
|
||||
for ip, geo in geo_map.items()
|
||||
if geo is not None and geo.country_code == country_code
|
||||
]
|
||||
|
||||
if source == "archive":
|
||||
if matched_ips:
|
||||
companion_rows = await get_all_archived_history(
|
||||
db=app_db,
|
||||
since=since,
|
||||
origin=origin,
|
||||
action="ban",
|
||||
ip_filter=matched_ips,
|
||||
)
|
||||
else:
|
||||
companion_rows = []
|
||||
else:
|
||||
if matched_ips:
|
||||
companion_rows, _ = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=since,
|
||||
origin=origin,
|
||||
ip_filter=matched_ips,
|
||||
)
|
||||
else:
|
||||
companion_rows = []
|
||||
|
||||
# Build country aggregation from the SQL-grouped rows.
|
||||
countries: dict[str, int] = {}
|
||||
country_names: dict[str, str] = {}
|
||||
|
||||
@@ -26,7 +26,7 @@ JOB_ID: str = "history_sync"
|
||||
HISTORY_SYNC_INTERVAL: int = 300
|
||||
|
||||
#: Backfill window when archive is empty (seconds).
|
||||
BACKFILL_WINDOW: int = 7 * 86400
|
||||
BACKFILL_WINDOW: int = 648000
|
||||
|
||||
|
||||
async def _get_last_archive_ts(db) -> int | None:
|
||||
@@ -50,7 +50,7 @@ async def _run_sync(app: FastAPI) -> None:
|
||||
log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW)
|
||||
|
||||
per_page = 500
|
||||
next_since = last_ts
|
||||
next_since = last_ts + 1
|
||||
total_synced = 0
|
||||
|
||||
while True:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bangui-backend"
|
||||
version = "0.9.15"
|
||||
version = "0.9.18"
|
||||
description = "BanGUI backend — fail2ban web management interface"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -80,6 +80,32 @@ async def test_get_currently_banned_filters_and_pagination(tmp_path: Path) -> No
|
||||
assert records[0].ip == "3.3.3.3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_currently_banned_filters_by_ip_list(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "fail2ban.db")
|
||||
async with aiosqlite.connect(db_path) as db:
|
||||
await _create_bans_table(db)
|
||||
await db.executemany(
|
||||
"INSERT INTO bans (jail, ip, timeofban, bancount, data) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("jail1", "1.1.1.1", 10, 1, "{}"),
|
||||
("jail1", "2.2.2.2", 20, 1, "{}"),
|
||||
("jail1", "3.3.3.3", 30, 1, "{}"),
|
||||
],
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
records, total = await fail2ban_db_repo.get_currently_banned(
|
||||
db_path=db_path,
|
||||
since=0,
|
||||
ip_filter=["2.2.2.2", "3.3.3.3"],
|
||||
)
|
||||
|
||||
assert total == 2
|
||||
assert len(records) == 2
|
||||
assert {record.ip for record in records} == {"2.2.2.2", "3.3.3.3"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ban_counts_by_bucket_ignores_out_of_range_buckets(tmp_path: Path) -> None:
|
||||
db_path = str(tmp_path / "fail2ban.db")
|
||||
|
||||
@@ -47,6 +47,10 @@ async def test_get_archived_history_filtering_and_pagination(app_db: str) -> Non
|
||||
assert total == 2
|
||||
assert len(rows) == 1
|
||||
|
||||
rows, total = await get_archived_history(db, ip_filter=["2.2.2.2"])
|
||||
assert total == 1
|
||||
assert rows[0]["ip"] == "2.2.2.2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purge_archived_history(app_db: str) -> None:
|
||||
|
||||
@@ -428,6 +428,15 @@ class TestBansByCountry:
|
||||
called_range = mock_fn.call_args[0][1]
|
||||
assert called_range == "7d"
|
||||
|
||||
async def test_invalid_source_returns_422(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""An invalid source value returns HTTP 422."""
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/bans/by-country?source=invalid"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_empty_window_returns_empty_response(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
@@ -513,6 +522,19 @@ class TestDashboardBansOriginField:
|
||||
|
||||
assert mock_fn.call_args[1]["source"] == "archive"
|
||||
|
||||
async def test_bans_by_country_country_code_forwarded(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""The ``country_code`` query parameter is forwarded to bans_by_country."""
|
||||
mock_fn = AsyncMock(return_value=_make_bans_by_country_response())
|
||||
with patch("app.routers.dashboard.ban_service.bans_by_country", new=mock_fn):
|
||||
await dashboard_client.get(
|
||||
"/api/dashboard/bans/by-country?country_code=DE"
|
||||
)
|
||||
|
||||
_, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("country_code") == "DE"
|
||||
|
||||
async def test_blocklist_origin_serialised_correctly(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
@@ -722,6 +744,15 @@ class TestBanTrend:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_invalid_source_returns_422(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""An invalid source value returns HTTP 422."""
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/bans/trend?source=invalid"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Empty bucket list is serialised correctly."""
|
||||
from app.models.ban import BanTrendResponse
|
||||
@@ -857,6 +888,15 @@ class TestBansByJail:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_invalid_source_returns_422(
|
||||
self, dashboard_client: AsyncClient
|
||||
) -> None:
|
||||
"""An invalid source value returns HTTP 422."""
|
||||
response = await dashboard_client.get(
|
||||
"/api/dashboard/bans/by-jail?source=invalid"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None:
|
||||
"""Empty jails list is serialised correctly."""
|
||||
from app.models.ban import BansByJailResponse
|
||||
|
||||
@@ -654,6 +654,54 @@ class TestOriginFilter:
|
||||
|
||||
assert result.total == 3
|
||||
|
||||
async def test_bans_by_country_country_code_returns_all_matched_rows(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""``bans_by_country`` returns all companion rows for the selected country."""
|
||||
path = str(tmp_path / "fail2ban_country_filter.sqlite3")
|
||||
rows = [
|
||||
{
|
||||
"jail": "sshd",
|
||||
"ip": "10.0.0.1",
|
||||
"timeofban": _ONE_HOUR_AGO - i,
|
||||
"bantime": 3600,
|
||||
"bancount": 1,
|
||||
"data": {"matches": ["failed login"]},
|
||||
}
|
||||
for i in range(205)
|
||||
]
|
||||
await _create_f2b_db(path, rows)
|
||||
|
||||
from app.services import geo_service
|
||||
|
||||
geo_service._cache["10.0.0.1"] = geo_service.GeoInfo(
|
||||
country_code="DE",
|
||||
country_name="Germany",
|
||||
asn=None,
|
||||
org=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.services.ban_service.get_fail2ban_db_path",
|
||||
new=AsyncMock(return_value=path),
|
||||
), patch(
|
||||
"app.services.ban_service.asyncio.create_task"
|
||||
) as mock_create_task:
|
||||
result = await ban_service.bans_by_country(
|
||||
"/fake/sock",
|
||||
"24h",
|
||||
country_code="DE",
|
||||
http_session=AsyncMock(),
|
||||
geo_cache_lookup=geo_service.lookup_cached_only,
|
||||
)
|
||||
|
||||
mock_create_task.assert_not_called()
|
||||
assert result.total == 205
|
||||
assert len(result.bans) == 205
|
||||
assert all(b.country_code == "DE" for b in result.bans)
|
||||
|
||||
geo_service.clear_cache()
|
||||
|
||||
async def test_bans_by_country_source_archive_reads_archive(
|
||||
self, app_db_with_archive: aiosqlite.Connection
|
||||
) -> None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.tasks import history_sync
|
||||
|
||||
@@ -27,3 +27,33 @@ class TestHistorySyncTask:
|
||||
called_args, called_kwargs = fake_scheduler.add_job.call_args
|
||||
assert called_kwargs["id"] == history_sync.JOB_ID
|
||||
assert called_kwargs["kwargs"]["app"] == app
|
||||
|
||||
async def test_backfill_window_is_7_5_days(self) -> None:
|
||||
assert history_sync.BACKFILL_WINDOW == 648000
|
||||
|
||||
async def test_sync_uses_strict_since_after_restart(self) -> None:
|
||||
fake_app = type("FakeApp", (), {})()
|
||||
fake_app.state = type("FakeState", (), {})()
|
||||
fake_app.state.settings = type("FakeSettings", (), {})()
|
||||
fake_app.state.settings.fail2ban_socket = "/tmp/fake.sock"
|
||||
|
||||
fake_app.state.db = MagicMock()
|
||||
|
||||
async def fake_get_history_page(*, db_path: str, since: int, page: int, page_size: int, **kwargs):
|
||||
assert since == 1001
|
||||
return [], 0
|
||||
|
||||
async def fake_get_fail2ban_db_path(socket_path: str) -> str:
|
||||
return "/tmp/fake.sqlite3"
|
||||
|
||||
with patch(
|
||||
"app.tasks.history_sync._get_last_archive_ts",
|
||||
new=AsyncMock(return_value=1000),
|
||||
), patch(
|
||||
"app.tasks.history_sync.get_fail2ban_db_path",
|
||||
new=fake_get_fail2ban_db_path,
|
||||
), patch(
|
||||
"app.tasks.history_sync.fail2ban_db_repo.get_history_page",
|
||||
new=fake_get_history_page,
|
||||
):
|
||||
await history_sync._run_sync(fake_app)
|
||||
|
||||
@@ -72,7 +72,7 @@ dbfile = /var/lib/fail2ban/fail2ban.sqlite3
|
||||
# Options: dbpurgeage
|
||||
# Notes.: Sets age at which bans should be purged from the database
|
||||
# Values: [ SECONDS ] Default: 86400 (24hours)
|
||||
dbpurgeage = 1d
|
||||
dbpurgeage = 648000
|
||||
|
||||
# Options: dbmaxmatches
|
||||
# Notes.: Number of matches stored in database per ticket (resolvable via
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.15",
|
||||
"version": "0.9.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.15",
|
||||
"version": "0.9.18",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.16",
|
||||
"version": "0.9.19",
|
||||
"description": "BanGUI frontend — fail2ban web management interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function fetchBans(
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): Promise<DashboardBanListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
range,
|
||||
@@ -51,6 +52,9 @@ export async function fetchBans(
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -66,11 +70,15 @@ export async function fetchBans(
|
||||
export async function fetchBanTrend(
|
||||
range: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): Promise<BanTrendResponse> {
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -86,10 +94,14 @@ export async function fetchBanTrend(
|
||||
export async function fetchBansByJail(
|
||||
range: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): Promise<BansByJailResponse> {
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function fetchHistory(
|
||||
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.source) params.set("source", query.source);
|
||||
if (query.page !== undefined) params.set("page", String(query.page));
|
||||
if (query.page_size !== undefined)
|
||||
params.set("page_size", String(query.page_size));
|
||||
|
||||
34
frontend/src/api/map.test.ts
Normal file
34
frontend/src/api/map.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import { ENDPOINTS } from "./endpoints";
|
||||
import { fetchBansByCountry } from "./map";
|
||||
import { get } from "./client";
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedGet = get as Mock;
|
||||
|
||||
describe("fetchBansByCountry", () => {
|
||||
beforeEach(() => {
|
||||
mockedGet.mockReset();
|
||||
mockedGet.mockResolvedValue({ countries: {}, country_names: {}, bans: [], total: 0 });
|
||||
});
|
||||
|
||||
it("appends country_code when provided", async () => {
|
||||
await fetchBansByCountry("24h", "all", "fail2ban", "US");
|
||||
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h&country_code=US`
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append country_code when undefined", async () => {
|
||||
await fetchBansByCountry("24h", "all", "fail2ban");
|
||||
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
`${ENDPOINTS.dashboardBansByCountry}?range=24h`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,18 @@ import type { BanOriginFilter } from "../types/ban";
|
||||
export async function fetchBansByCountry(
|
||||
range: TimeRange = "24h",
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
countryCode?: string,
|
||||
): Promise<BansByCountryResponse> {
|
||||
const params = new URLSearchParams({ range });
|
||||
if (origin !== "all") {
|
||||
params.set("origin", origin);
|
||||
}
|
||||
if (source !== "fail2ban") {
|
||||
params.set("source", source);
|
||||
}
|
||||
if (countryCode) {
|
||||
params.set("country_code", countryCode);
|
||||
}
|
||||
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ interface BanTableProps {
|
||||
* Changing this value triggers a re-fetch and resets to page 1.
|
||||
*/
|
||||
origin?: BanOriginFilter;
|
||||
/**
|
||||
* Data source used for the table query.
|
||||
*/
|
||||
source?: "fail2ban" | "archive";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -186,9 +190,9 @@ function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefin
|
||||
* @param props.timeRange - Active time-range preset from the parent page.
|
||||
* @param props.origin - Active origin filter from the parent page.
|
||||
*/
|
||||
export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element {
|
||||
export function BanTable({ timeRange, origin = "all", source = "fail2ban" }: BanTableProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin);
|
||||
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin, source);
|
||||
|
||||
const banColumns = buildBanColumns(styles);
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ interface BanTrendChartProps {
|
||||
timeRange: TimeRange;
|
||||
/** Origin filter controlling which bans are included. */
|
||||
origin: BanOriginFilter;
|
||||
/** Data source used for the chart. */
|
||||
source?: "fail2ban" | "archive";
|
||||
}
|
||||
|
||||
/** Internal chart data point shape. */
|
||||
@@ -188,9 +190,10 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
||||
export function BanTrendChart({
|
||||
timeRange,
|
||||
origin,
|
||||
source = "fail2ban",
|
||||
}: BanTrendChartProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin);
|
||||
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin, source);
|
||||
|
||||
const isEmpty = buckets.every((b) => b.count === 0);
|
||||
const entries = buildEntries(buckets, timeRange);
|
||||
|
||||
@@ -208,10 +208,16 @@ export function WorldMap({
|
||||
[onSelectCountry, selectedCountry],
|
||||
);
|
||||
|
||||
/** SVG-level click handler — paths never receive click when pointer capture
|
||||
* is active on the SVG, so we resolve the target via the data-cc attribute. */
|
||||
const handleSvgClick = useCallback((event: React.MouseEvent<SVGSVGElement>) => {
|
||||
const target = (event.target as Element).closest('[data-cc]');
|
||||
const cc = target?.getAttribute('data-cc') ?? null;
|
||||
if (cc) handleCountrySelect(cc);
|
||||
}, [handleCountrySelect]);
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStateRef.current = {
|
||||
active: true,
|
||||
startX: event.clientX,
|
||||
@@ -231,6 +237,7 @@ export function WorldMap({
|
||||
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||
drag.moved = true;
|
||||
clickSuppressedRef.current = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
|
||||
@@ -332,6 +339,7 @@ export function WorldMap({
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onClick={handleSvgClick}
|
||||
>
|
||||
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||
{countryFeatures.map((featureItem) => {
|
||||
@@ -346,14 +354,11 @@ export function WorldMap({
|
||||
return null;
|
||||
}
|
||||
|
||||
const centroid = pathGenerator.centroid(featureItem);
|
||||
const [cx, cy] = centroid;
|
||||
const isCentroidValid = Number.isFinite(cx) && Number.isFinite(cy);
|
||||
|
||||
return (
|
||||
<g key={String(rawId)}>
|
||||
<path
|
||||
d={pathString}
|
||||
data-cc={cc ?? undefined}
|
||||
role={cc ? "button" : undefined}
|
||||
tabIndex={cc ? 0 : undefined}
|
||||
aria-label={
|
||||
@@ -377,9 +382,7 @@ export function WorldMap({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (cc) {
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
if (cc) handleCountrySelect(cc);
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (cc && (event.key === "Enter" || event.key === " ")) {
|
||||
@@ -410,17 +413,6 @@ export function WorldMap({
|
||||
setTooltip(null);
|
||||
}}
|
||||
/>
|
||||
{count > 0 && isCentroidValid && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface UseBanTrendResult {
|
||||
export function useBanTrend(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter,
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): UseBanTrendResult {
|
||||
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
|
||||
const [bucketSize, setBucketSize] = useState<string>("1h");
|
||||
@@ -58,7 +59,7 @@ export function useBanTrend(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBanTrend(timeRange, origin)
|
||||
fetchBanTrend(timeRange, origin, source)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setBuckets(data.buckets);
|
||||
@@ -73,7 +74,7 @@ export function useBanTrend(
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [timeRange, origin]);
|
||||
}, [timeRange, origin, source]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface UseBansResult {
|
||||
export function useBans(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): UseBansResult {
|
||||
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
@@ -51,16 +52,16 @@ export function useBans(
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset page when time range or origin filter changes.
|
||||
// Reset page when time range, origin filter, or source changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [timeRange, origin]);
|
||||
}, [timeRange, origin, source]);
|
||||
|
||||
const doFetch = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin);
|
||||
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin, source);
|
||||
setBanItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (err: unknown) {
|
||||
@@ -68,7 +69,7 @@ export function useBans(
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange, page, origin]);
|
||||
}, [timeRange, page, origin, source]);
|
||||
|
||||
// Stable ref to the latest doFetch so the refresh callback is always current.
|
||||
const doFetchRef = useRef(doFetch);
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface UseDashboardCountryDataResult {
|
||||
export function useDashboardCountryData(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter,
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
): UseDashboardCountryDataResult {
|
||||
const [countries, setCountries] = useState<Record<string, number>>({});
|
||||
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
||||
@@ -67,7 +68,7 @@ export function useDashboardCountryData(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBansByCountry(timeRange, origin)
|
||||
fetchBansByCountry(timeRange, origin, source)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCountries(data.countries);
|
||||
@@ -85,7 +86,7 @@ export function useDashboardCountryData(
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [timeRange, origin]);
|
||||
}, [timeRange, origin, source]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface UseMapDataResult {
|
||||
export function useMapData(
|
||||
range: TimeRange = "24h",
|
||||
origin: BanOriginFilter = "all",
|
||||
source: "fail2ban" | "archive" = "fail2ban",
|
||||
countryCode?: string,
|
||||
): UseMapDataResult {
|
||||
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -64,7 +66,7 @@ export function useMapData(
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
fetchBansByCountry(range, origin)
|
||||
fetchBansByCountry(range, origin, source, countryCode)
|
||||
.then((resp) => {
|
||||
setData(resp);
|
||||
})
|
||||
@@ -75,7 +77,7 @@ export function useMapData(
|
||||
setLoading(false);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
}, [range, origin]);
|
||||
}, [range, origin, source, countryCode]);
|
||||
|
||||
useEffect((): (() => void) => {
|
||||
load();
|
||||
|
||||
@@ -71,8 +71,10 @@ export function DashboardPage(): React.JSX.Element {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
|
||||
const source = timeRange === "24h" ? "fail2ban" : "archive";
|
||||
|
||||
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
|
||||
useDashboardCountryData(timeRange, originFilter);
|
||||
useDashboardCountryData(timeRange, originFilter, source);
|
||||
|
||||
const sectionStyles = useCommonSectionStyles();
|
||||
|
||||
@@ -86,12 +88,14 @@ export function DashboardPage(): React.JSX.Element {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Global filter bar */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<DashboardFilterBar
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={setOriginFilter}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
|
||||
<DashboardFilterBar
|
||||
timeRange={timeRange}
|
||||
onTimeRangeChange={setTimeRange}
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={setOriginFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Ban Trend section */}
|
||||
@@ -103,7 +107,7 @@ export function DashboardPage(): React.JSX.Element {
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
<BanTrendChart timeRange={timeRange} origin={originFilter} />
|
||||
<BanTrendChart timeRange={timeRange} origin={originFilter} source={source} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +158,7 @@ export function DashboardPage(): React.JSX.Element {
|
||||
|
||||
{/* Ban table */}
|
||||
<div className={styles.tabContent}>
|
||||
<BanTable timeRange={timeRange} origin={originFilter} />
|
||||
<BanTable timeRange={timeRange} origin={originFilter} source={source} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +143,7 @@ function areHistoryQueriesEqual(
|
||||
a.origin === b.origin &&
|
||||
a.jail === b.jail &&
|
||||
a.ip === b.ip &&
|
||||
a.source === b.source &&
|
||||
a.page === b.page &&
|
||||
a.page_size === b.page_size
|
||||
);
|
||||
@@ -386,11 +387,12 @@ export function HistoryPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
// Filter state
|
||||
const [range, setRange] = useState<TimeRange>("24h");
|
||||
const [range, setRange] = useState<TimeRange>("7d");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
const [jailFilter, setJailFilter] = useState("");
|
||||
const [ipFilter, setIpFilter] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
||||
source: "archive",
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
|
||||
@@ -406,6 +408,7 @@ export function HistoryPage(): React.JSX.Element {
|
||||
origin: originFilter !== "all" ? originFilter : undefined,
|
||||
jail: jailFilter.trim() || undefined,
|
||||
ip: ipFilter.trim() || undefined,
|
||||
source: "archive",
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
};
|
||||
@@ -487,23 +490,6 @@ export function HistoryPage(): React.JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Error / loading state */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{error && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}
|
||||
>
|
||||
<Spinner label="Loading history…" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Summary */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
|
||||
@@ -64,6 +64,13 @@ const useStyles = makeStyles({
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
},
|
||||
stickyHeaderCell: {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
boxShadow: `0 1px 0 ${tokens.colorNeutralStroke2}`,
|
||||
},
|
||||
filterBar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -81,6 +88,9 @@ const useStyles = makeStyles({
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
position: "sticky",
|
||||
bottom: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,8 +108,17 @@ export function MapPage(): React.JSX.Element {
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
|
||||
|
||||
const source = range === "24h" ? "fail2ban" : "archive";
|
||||
|
||||
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||
useMapData(range, originFilter);
|
||||
useMapData(range, originFilter, source, selectedCountry ?? undefined);
|
||||
|
||||
// True after the first successful data load — keeps the map mounted
|
||||
// during subsequent re-fetches so country selection gives instant feedback.
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!loading && !error) setHasLoadedOnce(true);
|
||||
}, [loading, error]);
|
||||
|
||||
const {
|
||||
thresholds: mapThresholds,
|
||||
@@ -183,7 +202,8 @@ export function MapPage(): React.JSX.Element {
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
{/* Initial load spinner — only shown before the first data arrives. */}
|
||||
{loading && !error && !hasLoadedOnce && (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
|
||||
<Spinner label="Loading map data…" />
|
||||
</div>
|
||||
@@ -191,8 +211,10 @@ export function MapPage(): React.JSX.Element {
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* World map */}
|
||||
{/* Keep the map mounted after first load so clicking a country gives */}
|
||||
{/* immediate visual feedback before the filtered data arrives. */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
{!error && hasLoadedOnce && (
|
||||
<WorldMap
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
@@ -230,28 +252,31 @@ export function MapPage(): React.JSX.Element {
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Summary line */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period
|
||||
{" · "}
|
||||
{String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected
|
||||
</Text>
|
||||
{!error && hasLoadedOnce && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
|
||||
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
{String(total)} total ban{total !== 1 ? "s" : ""} in the selected period
|
||||
{" · "}
|
||||
{String(Object.keys(countries).length)} countr{Object.keys(countries).length !== 1 ? "ies" : "y"} affected
|
||||
</Text>
|
||||
{loading && <Spinner size="tiny" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Companion bans table */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{!loading && !error && (
|
||||
<div className={styles.tableWrapper}>
|
||||
{!error && hasLoadedOnce && (
|
||||
<div className={styles.tableWrapper} style={{ opacity: loading ? 0.5 : 1, transition: "opacity 150ms" }}>
|
||||
<Table size="small" aria-label="Bans list">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>IP Address</TableHeaderCell>
|
||||
<TableHeaderCell>Jail</TableHeaderCell>
|
||||
<TableHeaderCell>Banned At</TableHeaderCell>
|
||||
<TableHeaderCell>Country</TableHeaderCell>
|
||||
<TableHeaderCell>Origin</TableHeaderCell>
|
||||
<TableHeaderCell>Times Banned</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>IP Address</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Jail</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Banned At</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Country</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Origin</TableHeaderCell>
|
||||
<TableHeaderCell className={styles.stickyHeaderCell}>Times Banned</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -50,7 +50,8 @@ describe("HistoryPage", () => {
|
||||
// Initial load should include the auto-applied default query.
|
||||
await waitFor(() => {
|
||||
expect(lastQuery).toEqual({
|
||||
range: "24h",
|
||||
range: "7d",
|
||||
source: "archive",
|
||||
origin: undefined,
|
||||
jail: undefined,
|
||||
ip: undefined,
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface HistoryQuery {
|
||||
origin?: BanOriginFilter;
|
||||
jail?: string;
|
||||
ip?: string;
|
||||
source?: "fail2ban" | "archive";
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user