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:
2026-04-05 20:21:54 +02:00
parent 7d09b78437
commit ffaa14f864
21 changed files with 149 additions and 37 deletions

View File

@@ -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()}`);
}

View File

@@ -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));

View File

@@ -17,10 +17,14 @@ import type { BanOriginFilter } from "../types/ban";
export async function fetchBansByCountry(
range: TimeRange = "24h",
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): Promise<BansByCountryResponse> {
const params = new URLSearchParams({ range });
if (origin !== "all") {
params.set("origin", origin);
}
if (source !== "fail2ban") {
params.set("source", source);
}
return get<BansByCountryResponse>(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -43,6 +43,7 @@ export interface UseMapDataResult {
export function useMapData(
range: TimeRange = "24h",
origin: BanOriginFilter = "all",
source: "fail2ban" | "archive" = "fail2ban",
): UseMapDataResult {
const [data, setData] = useState<BansByCountryResponse | null>(null);
const [loading, setLoading] = useState(true);
@@ -64,7 +65,7 @@ export function useMapData(
abortRef.current?.abort();
abortRef.current = new AbortController();
fetchBansByCountry(range, origin)
fetchBansByCountry(range, origin, source)
.then((resp) => {
setData(resp);
})
@@ -75,7 +76,7 @@ export function useMapData(
setLoading(false);
});
}, DEBOUNCE_MS);
}, [range, origin]);
}, [range, origin, source]);
useEffect((): (() => void) => {
load();

View File

@@ -7,7 +7,7 @@
*/
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 { BanTrendChart } from "../components/BanTrendChart";
import { ChartStateWrapper } from "../components/ChartStateWrapper";
@@ -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,17 @@ 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}
/>
<Badge appearance="filled" color={source === "archive" ? "brand" : "success"}>
{source === "archive" ? "Archive (BanGUI DB)" : "Live (fail2ban DB)"}
</Badge>
</div>
{/* ------------------------------------------------------------------ */}
{/* Ban Trend section */}
@@ -103,7 +110,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 +161,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>

View File

@@ -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,
});
@@ -400,12 +402,15 @@ export function HistoryPage(): React.JSX.Element {
const { items, total, page, loading, error, setPage, refresh } =
useHistory(appliedQuery);
const sourceLabel = "Archive (BanGUI DB)";
useEffect((): void => {
const nextQuery: HistoryQuery = {
range,
origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined,
source: "archive",
page: 1,
page_size: PAGE_SIZE,
};
@@ -485,6 +490,9 @@ export function HistoryPage(): React.JSX.Element {
setIpFilter(value);
}}
/>
<Badge appearance="filled" color="brand" style={{ alignSelf: "flex-start" }}>
{sourceLabel}
</Badge>
</div>
{/* ---------------------------------------------------------------- */}

View File

@@ -98,8 +98,10 @@ 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);
const {
thresholds: mapThresholds,
@@ -163,6 +165,9 @@ export function MapPage(): React.JSX.Element {
setSelectedCountry(null);
}}
/>
<Badge appearance="filled" color={source === "archive" ? "brand" : "success"}>
{source === "archive" ? "Archive (BanGUI DB)" : "Live (fail2ban DB)"}
</Badge>
<Button
icon={<ArrowCounterclockwiseRegular />}
onClick={(): void => {

View File

@@ -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,

View File

@@ -57,6 +57,7 @@ export interface HistoryQuery {
origin?: BanOriginFilter;
jail?: string;
ip?: string;
source?: "fail2ban" | "archive";
page?: number;
page_size?: number;
}