Switch dashboard/map/history views to archive source for long-term data
Update fail2ban dbpurgeage to 648000 and history sync backfill/pagination for archive-based 7.5 day history.
This commit is contained in:
@@ -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`
|
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
||||||
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
(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`:
|
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -83,7 +83,7 @@ async def get_dashboard_bans(
|
|||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
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: 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."),
|
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
|
||||||
origin: BanOrigin | None = Query(
|
origin: BanOrigin | None = Query(
|
||||||
@@ -137,7 +137,7 @@ async def get_bans_by_country(
|
|||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
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(
|
origin: BanOrigin | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
@@ -185,7 +185,7 @@ async def get_ban_trend(
|
|||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
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(
|
origin: BanOrigin | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
@@ -235,7 +235,7 @@ async def get_bans_by_jail(
|
|||||||
request: Request,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
|
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(
|
origin: BanOrigin | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Routes
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -56,7 +56,7 @@ async def get_history(
|
|||||||
default=None,
|
default=None,
|
||||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
),
|
),
|
||||||
source: str = Query(
|
source: Literal["fail2ban", "archive"] = Query(
|
||||||
default="fail2ban",
|
default="fail2ban",
|
||||||
description="Data source: 'fail2ban' or 'archive'.",
|
description="Data source: 'fail2ban' or 'archive'.",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ JOB_ID: str = "history_sync"
|
|||||||
HISTORY_SYNC_INTERVAL: int = 300
|
HISTORY_SYNC_INTERVAL: int = 300
|
||||||
|
|
||||||
#: Backfill window when archive is empty (seconds).
|
#: 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:
|
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)
|
log.info("history_sync_backfill", window_seconds=BACKFILL_WINDOW)
|
||||||
|
|
||||||
per_page = 500
|
per_page = 500
|
||||||
next_since = last_ts
|
next_since = last_ts + 1
|
||||||
total_synced = 0
|
total_synced = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -428,6 +428,15 @@ class TestBansByCountry:
|
|||||||
called_range = mock_fn.call_args[0][1]
|
called_range = mock_fn.call_args[0][1]
|
||||||
assert called_range == "7d"
|
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(
|
async def test_empty_window_returns_empty_response(
|
||||||
self, dashboard_client: AsyncClient
|
self, dashboard_client: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -722,6 +731,15 @@ class TestBanTrend:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
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:
|
async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None:
|
||||||
"""Empty bucket list is serialised correctly."""
|
"""Empty bucket list is serialised correctly."""
|
||||||
from app.models.ban import BanTrendResponse
|
from app.models.ban import BanTrendResponse
|
||||||
@@ -857,6 +875,15 @@ class TestBansByJail:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
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:
|
async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None:
|
||||||
"""Empty jails list is serialised correctly."""
|
"""Empty jails list is serialised correctly."""
|
||||||
from app.models.ban import BansByJailResponse
|
from app.models.ban import BansByJailResponse
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from app.tasks import history_sync
|
from app.tasks import history_sync
|
||||||
|
|
||||||
@@ -27,3 +27,33 @@ class TestHistorySyncTask:
|
|||||||
called_args, called_kwargs = fake_scheduler.add_job.call_args
|
called_args, called_kwargs = fake_scheduler.add_job.call_args
|
||||||
assert called_kwargs["id"] == history_sync.JOB_ID
|
assert called_kwargs["id"] == history_sync.JOB_ID
|
||||||
assert called_kwargs["kwargs"]["app"] == app
|
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
|
# Options: dbpurgeage
|
||||||
# Notes.: Sets age at which bans should be purged from the database
|
# Notes.: Sets age at which bans should be purged from the database
|
||||||
# Values: [ SECONDS ] Default: 86400 (24hours)
|
# Values: [ SECONDS ] Default: 86400 (24hours)
|
||||||
dbpurgeage = 1d
|
dbpurgeage = 648000
|
||||||
|
|
||||||
# Options: dbmaxmatches
|
# Options: dbmaxmatches
|
||||||
# Notes.: Number of matches stored in database per ticket (resolvable via
|
# Notes.: Number of matches stored in database per ticket (resolvable via
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function fetchBans(
|
|||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 100,
|
pageSize = 100,
|
||||||
origin: BanOriginFilter = "all",
|
origin: BanOriginFilter = "all",
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): Promise<DashboardBanListResponse> {
|
): Promise<DashboardBanListResponse> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
range,
|
range,
|
||||||
@@ -51,6 +52,9 @@ export async function fetchBans(
|
|||||||
if (origin !== "all") {
|
if (origin !== "all") {
|
||||||
params.set("origin", origin);
|
params.set("origin", origin);
|
||||||
}
|
}
|
||||||
|
if (source !== "fail2ban") {
|
||||||
|
params.set("source", source);
|
||||||
|
}
|
||||||
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +70,15 @@ export async function fetchBans(
|
|||||||
export async function fetchBanTrend(
|
export async function fetchBanTrend(
|
||||||
range: TimeRange,
|
range: TimeRange,
|
||||||
origin: BanOriginFilter = "all",
|
origin: BanOriginFilter = "all",
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): Promise<BanTrendResponse> {
|
): Promise<BanTrendResponse> {
|
||||||
const params = new URLSearchParams({ range });
|
const params = new URLSearchParams({ range });
|
||||||
if (origin !== "all") {
|
if (origin !== "all") {
|
||||||
params.set("origin", origin);
|
params.set("origin", origin);
|
||||||
}
|
}
|
||||||
|
if (source !== "fail2ban") {
|
||||||
|
params.set("source", source);
|
||||||
|
}
|
||||||
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
|
return get<BanTrendResponse>(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +94,14 @@ export async function fetchBanTrend(
|
|||||||
export async function fetchBansByJail(
|
export async function fetchBansByJail(
|
||||||
range: TimeRange,
|
range: TimeRange,
|
||||||
origin: BanOriginFilter = "all",
|
origin: BanOriginFilter = "all",
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): Promise<BansByJailResponse> {
|
): Promise<BansByJailResponse> {
|
||||||
const params = new URLSearchParams({ range });
|
const params = new URLSearchParams({ range });
|
||||||
if (origin !== "all") {
|
if (origin !== "all") {
|
||||||
params.set("origin", origin);
|
params.set("origin", origin);
|
||||||
}
|
}
|
||||||
|
if (source !== "fail2ban") {
|
||||||
|
params.set("source", source);
|
||||||
|
}
|
||||||
return get<BansByJailResponse>(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`);
|
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.origin) params.set("origin", query.origin);
|
||||||
if (query.jail) params.set("jail", query.jail);
|
if (query.jail) params.set("jail", query.jail);
|
||||||
if (query.ip) params.set("ip", query.ip);
|
if (query.ip) params.set("ip", query.ip);
|
||||||
|
if (query.source) params.set("source", query.source);
|
||||||
if (query.page !== undefined) params.set("page", String(query.page));
|
if (query.page !== undefined) params.set("page", String(query.page));
|
||||||
if (query.page_size !== undefined)
|
if (query.page_size !== undefined)
|
||||||
params.set("page_size", String(query.page_size));
|
params.set("page_size", String(query.page_size));
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ import type { BanOriginFilter } from "../types/ban";
|
|||||||
export async function fetchBansByCountry(
|
export async function fetchBansByCountry(
|
||||||
range: TimeRange = "24h",
|
range: TimeRange = "24h",
|
||||||
origin: BanOriginFilter = "all",
|
origin: BanOriginFilter = "all",
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): Promise<BansByCountryResponse> {
|
): Promise<BansByCountryResponse> {
|
||||||
const params = new URLSearchParams({ range });
|
const params = new URLSearchParams({ range });
|
||||||
if (origin !== "all") {
|
if (origin !== "all") {
|
||||||
params.set("origin", origin);
|
params.set("origin", origin);
|
||||||
}
|
}
|
||||||
|
if (source !== "fail2ban") {
|
||||||
|
params.set("source", source);
|
||||||
|
}
|
||||||
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
|
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.
|
* Changing this value triggers a re-fetch and resets to page 1.
|
||||||
*/
|
*/
|
||||||
origin?: BanOriginFilter;
|
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.timeRange - Active time-range preset from the parent page.
|
||||||
* @param props.origin - Active origin filter 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 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);
|
const banColumns = buildBanColumns(styles);
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ interface BanTrendChartProps {
|
|||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
/** Origin filter controlling which bans are included. */
|
/** Origin filter controlling which bans are included. */
|
||||||
origin: BanOriginFilter;
|
origin: BanOriginFilter;
|
||||||
|
/** Data source used for the chart. */
|
||||||
|
source?: "fail2ban" | "archive";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Internal chart data point shape. */
|
/** Internal chart data point shape. */
|
||||||
@@ -188,9 +190,10 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
|||||||
export function BanTrendChart({
|
export function BanTrendChart({
|
||||||
timeRange,
|
timeRange,
|
||||||
origin,
|
origin,
|
||||||
|
source = "fail2ban",
|
||||||
}: BanTrendChartProps): React.JSX.Element {
|
}: BanTrendChartProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
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 isEmpty = buckets.every((b) => b.count === 0);
|
||||||
const entries = buildEntries(buckets, timeRange);
|
const entries = buildEntries(buckets, timeRange);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface UseBanTrendResult {
|
|||||||
export function useBanTrend(
|
export function useBanTrend(
|
||||||
timeRange: TimeRange,
|
timeRange: TimeRange,
|
||||||
origin: BanOriginFilter,
|
origin: BanOriginFilter,
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): UseBanTrendResult {
|
): UseBanTrendResult {
|
||||||
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
|
const [buckets, setBuckets] = useState<BanTrendBucket[]>([]);
|
||||||
const [bucketSize, setBucketSize] = useState<string>("1h");
|
const [bucketSize, setBucketSize] = useState<string>("1h");
|
||||||
@@ -58,7 +59,7 @@ export function useBanTrend(
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
fetchBanTrend(timeRange, origin)
|
fetchBanTrend(timeRange, origin, source)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setBuckets(data.buckets);
|
setBuckets(data.buckets);
|
||||||
@@ -73,7 +74,7 @@ export function useBanTrend(
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [timeRange, origin]);
|
}, [timeRange, origin, source]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface UseBansResult {
|
|||||||
export function useBans(
|
export function useBans(
|
||||||
timeRange: TimeRange,
|
timeRange: TimeRange,
|
||||||
origin: BanOriginFilter = "all",
|
origin: BanOriginFilter = "all",
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): UseBansResult {
|
): UseBansResult {
|
||||||
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
|
||||||
const [total, setTotal] = useState<number>(0);
|
const [total, setTotal] = useState<number>(0);
|
||||||
@@ -51,16 +52,16 @@ export function useBans(
|
|||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [timeRange, origin]);
|
}, [timeRange, origin, source]);
|
||||||
|
|
||||||
const doFetch = useCallback(async (): Promise<void> => {
|
const doFetch = useCallback(async (): Promise<void> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin);
|
const data = await fetchBans(timeRange, page, PAGE_SIZE, origin, source);
|
||||||
setBanItems(data.items);
|
setBanItems(data.items);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -68,7 +69,7 @@ export function useBans(
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [timeRange, page, origin]);
|
}, [timeRange, page, origin, source]);
|
||||||
|
|
||||||
// Stable ref to the latest doFetch so the refresh callback is always current.
|
// Stable ref to the latest doFetch so the refresh callback is always current.
|
||||||
const doFetchRef = useRef(doFetch);
|
const doFetchRef = useRef(doFetch);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface UseDashboardCountryDataResult {
|
|||||||
export function useDashboardCountryData(
|
export function useDashboardCountryData(
|
||||||
timeRange: TimeRange,
|
timeRange: TimeRange,
|
||||||
origin: BanOriginFilter,
|
origin: BanOriginFilter,
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): UseDashboardCountryDataResult {
|
): UseDashboardCountryDataResult {
|
||||||
const [countries, setCountries] = useState<Record<string, number>>({});
|
const [countries, setCountries] = useState<Record<string, number>>({});
|
||||||
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
||||||
@@ -67,7 +68,7 @@ export function useDashboardCountryData(
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
fetchBansByCountry(timeRange, origin)
|
fetchBansByCountry(timeRange, origin, source)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setCountries(data.countries);
|
setCountries(data.countries);
|
||||||
@@ -85,7 +86,7 @@ export function useDashboardCountryData(
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [timeRange, origin]);
|
}, [timeRange, origin, source]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface UseMapDataResult {
|
|||||||
export function useMapData(
|
export function useMapData(
|
||||||
range: TimeRange = "24h",
|
range: TimeRange = "24h",
|
||||||
origin: BanOriginFilter = "all",
|
origin: BanOriginFilter = "all",
|
||||||
|
source: "fail2ban" | "archive" = "fail2ban",
|
||||||
): UseMapDataResult {
|
): UseMapDataResult {
|
||||||
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
const [data, setData] = useState<BansByCountryResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -64,7 +65,7 @@ export function useMapData(
|
|||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = new AbortController();
|
abortRef.current = new AbortController();
|
||||||
|
|
||||||
fetchBansByCountry(range, origin)
|
fetchBansByCountry(range, origin, source)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setData(resp);
|
setData(resp);
|
||||||
})
|
})
|
||||||
@@ -75,7 +76,7 @@ export function useMapData(
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
}, [range, origin]);
|
}, [range, origin, source]);
|
||||||
|
|
||||||
useEffect((): (() => void) => {
|
useEffect((): (() => void) => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
import { Badge, Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||||
import { BanTable } from "../components/BanTable";
|
import { BanTable } from "../components/BanTable";
|
||||||
import { BanTrendChart } from "../components/BanTrendChart";
|
import { BanTrendChart } from "../components/BanTrendChart";
|
||||||
import { ChartStateWrapper } from "../components/ChartStateWrapper";
|
import { ChartStateWrapper } from "../components/ChartStateWrapper";
|
||||||
@@ -71,8 +71,10 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
||||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||||
|
|
||||||
|
const source = timeRange === "24h" ? "fail2ban" : "archive";
|
||||||
|
|
||||||
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
|
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
|
||||||
useDashboardCountryData(timeRange, originFilter);
|
useDashboardCountryData(timeRange, originFilter, source);
|
||||||
|
|
||||||
const sectionStyles = useCommonSectionStyles();
|
const sectionStyles = useCommonSectionStyles();
|
||||||
|
|
||||||
@@ -86,12 +88,17 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* Global filter bar */}
|
{/* Global filter bar */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
<DashboardFilterBar
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
|
||||||
timeRange={timeRange}
|
<DashboardFilterBar
|
||||||
onTimeRangeChange={setTimeRange}
|
timeRange={timeRange}
|
||||||
originFilter={originFilter}
|
onTimeRangeChange={setTimeRange}
|
||||||
onOriginFilterChange={setOriginFilter}
|
originFilter={originFilter}
|
||||||
/>
|
onOriginFilterChange={setOriginFilter}
|
||||||
|
/>
|
||||||
|
<Badge appearance="filled" color={source === "archive" ? "brand" : "success"}>
|
||||||
|
{source === "archive" ? "Archive (BanGUI DB)" : "Live (fail2ban DB)"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* Ban Trend section */}
|
{/* Ban Trend section */}
|
||||||
@@ -103,7 +110,7 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.tabContent}>
|
<div className={styles.tabContent}>
|
||||||
<BanTrendChart timeRange={timeRange} origin={originFilter} />
|
<BanTrendChart timeRange={timeRange} origin={originFilter} source={source} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,7 +161,7 @@ export function DashboardPage(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Ban table */}
|
{/* Ban table */}
|
||||||
<div className={styles.tabContent}>
|
<div className={styles.tabContent}>
|
||||||
<BanTable timeRange={timeRange} origin={originFilter} />
|
<BanTable timeRange={timeRange} origin={originFilter} source={source} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function areHistoryQueriesEqual(
|
|||||||
a.origin === b.origin &&
|
a.origin === b.origin &&
|
||||||
a.jail === b.jail &&
|
a.jail === b.jail &&
|
||||||
a.ip === b.ip &&
|
a.ip === b.ip &&
|
||||||
|
a.source === b.source &&
|
||||||
a.page === b.page &&
|
a.page === b.page &&
|
||||||
a.page_size === b.page_size
|
a.page_size === b.page_size
|
||||||
);
|
);
|
||||||
@@ -386,11 +387,12 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [range, setRange] = useState<TimeRange>("24h");
|
const [range, setRange] = useState<TimeRange>("7d");
|
||||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||||
const [jailFilter, setJailFilter] = useState("");
|
const [jailFilter, setJailFilter] = useState("");
|
||||||
const [ipFilter, setIpFilter] = useState("");
|
const [ipFilter, setIpFilter] = useState("");
|
||||||
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
||||||
|
source: "archive",
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -400,12 +402,15 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
const { items, total, page, loading, error, setPage, refresh } =
|
const { items, total, page, loading, error, setPage, refresh } =
|
||||||
useHistory(appliedQuery);
|
useHistory(appliedQuery);
|
||||||
|
|
||||||
|
const sourceLabel = "Archive (BanGUI DB)";
|
||||||
|
|
||||||
useEffect((): void => {
|
useEffect((): void => {
|
||||||
const nextQuery: HistoryQuery = {
|
const nextQuery: HistoryQuery = {
|
||||||
range,
|
range,
|
||||||
origin: originFilter !== "all" ? originFilter : undefined,
|
origin: originFilter !== "all" ? originFilter : undefined,
|
||||||
jail: jailFilter.trim() || undefined,
|
jail: jailFilter.trim() || undefined,
|
||||||
ip: ipFilter.trim() || undefined,
|
ip: ipFilter.trim() || undefined,
|
||||||
|
source: "archive",
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
};
|
};
|
||||||
@@ -485,6 +490,9 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
setIpFilter(value);
|
setIpFilter(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Badge appearance="filled" color="brand" style={{ alignSelf: "flex-start" }}>
|
||||||
|
{sourceLabel}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
|
|||||||
@@ -98,8 +98,10 @@ export function MapPage(): React.JSX.Element {
|
|||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
|
const PAGE_SIZE_OPTIONS = [25, 50, 100] as const;
|
||||||
|
|
||||||
|
const source = range === "24h" ? "fail2ban" : "archive";
|
||||||
|
|
||||||
const { countries, countryNames, bans, total, loading, error, refresh } =
|
const { countries, countryNames, bans, total, loading, error, refresh } =
|
||||||
useMapData(range, originFilter);
|
useMapData(range, originFilter, source);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
thresholds: mapThresholds,
|
thresholds: mapThresholds,
|
||||||
@@ -163,6 +165,9 @@ export function MapPage(): React.JSX.Element {
|
|||||||
setSelectedCountry(null);
|
setSelectedCountry(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Badge appearance="filled" color={source === "archive" ? "brand" : "success"}>
|
||||||
|
{source === "archive" ? "Archive (BanGUI DB)" : "Live (fail2ban DB)"}
|
||||||
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowCounterclockwiseRegular />}
|
icon={<ArrowCounterclockwiseRegular />}
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ describe("HistoryPage", () => {
|
|||||||
// Initial load should include the auto-applied default query.
|
// Initial load should include the auto-applied default query.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(lastQuery).toEqual({
|
expect(lastQuery).toEqual({
|
||||||
range: "24h",
|
range: "7d",
|
||||||
|
source: "archive",
|
||||||
origin: undefined,
|
origin: undefined,
|
||||||
jail: undefined,
|
jail: undefined,
|
||||||
ip: undefined,
|
ip: undefined,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface HistoryQuery {
|
|||||||
origin?: BanOriginFilter;
|
origin?: BanOriginFilter;
|
||||||
jail?: string;
|
jail?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
|
source?: "fail2ban" | "archive";
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user