feat: implement dashboard ban overview (Stage 5)

- Add ban_service reading fail2ban SQLite DB via read-only aiosqlite
- Add geo_service resolving IPs via ip-api.com with 10k in-memory cache
- Add GET /api/dashboard/bans and GET /api/dashboard/accesses endpoints
- Add TimeRange, DashboardBanItem, DashboardBanListResponse, AccessListItem,
  AccessListResponse models in models/ban.py
- Build BanTable component (Fluent UI DataGrid) with bans/accesses modes,
  pagination, loading/error/empty states, and ban-count badges
- Build useBans hook managing time-range and pagination state
- Update DashboardPage: status bar + time-range toolbar + tab switcher
- Add 37 new backend tests (ban service, geo service, dashboard router)
- All 141 tests pass; ruff/mypy --strict/tsc --noEmit clean
This commit is contained in:
2026-03-01 12:57:19 +01:00
parent 94661d7877
commit 9ac7f8d22d
15 changed files with 2346 additions and 29 deletions

View File

@@ -1,20 +1,68 @@
/**
* Dashboard API module.
*
* Wraps the `GET /api/dashboard/status` endpoint.
* Wraps `GET /api/dashboard/status`, `GET /api/dashboard/bans`, and
* `GET /api/dashboard/accesses`.
*/
import { get } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { AccessListResponse, DashboardBanListResponse, TimeRange } from "../types/ban";
import type { ServerStatusResponse } from "../types/server";
/**
* Fetch the cached fail2ban server status from the backend.
*
* @returns The server status response containing ``online``, ``version``,
* ``active_jails``, ``total_bans``, and ``total_failures``.
* @returns The server status response containing `online`, `version`,
* `active_jails`, `total_bans`, and `total_failures`.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchServerStatus(): Promise<ServerStatusResponse> {
return get<ServerStatusResponse>(ENDPOINTS.dashboardStatus);
}
/**
* Fetch a paginated ban list for the selected time window.
*
* @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
* @param page - 1-based page number (default `1`).
* @param pageSize - Items per page (default `100`).
* @returns Paginated {@link DashboardBanListResponse}.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchBans(
range: TimeRange,
page = 1,
pageSize = 100,
): Promise<DashboardBanListResponse> {
const params = new URLSearchParams({
range,
page: String(page),
page_size: String(pageSize),
});
return get<DashboardBanListResponse>(`${ENDPOINTS.dashboardBans}?${params.toString()}`);
}
/**
* Fetch a paginated access list (individual matched log lines) for the
* selected time window.
*
* @param range - Time-range preset.
* @param page - 1-based page number (default `1`).
* @param pageSize - Items per page (default `100`).
* @returns Paginated {@link AccessListResponse}.
* @throws {ApiError} When the server returns a non-2xx status.
*/
export async function fetchAccesses(
range: TimeRange,
page = 1,
pageSize = 100,
): Promise<AccessListResponse> {
const params = new URLSearchParams({
range,
page: String(page),
page_size: String(pageSize),
});
return get<AccessListResponse>(`/api/dashboard/accesses?${params.toString()}`);
}

View File

@@ -0,0 +1,394 @@
/**
* `BanTable` component.
*
* Renders a Fluent UI v9 `DataGrid` for the dashboard ban-list and
* access-list views. Uses the {@link useBans} hook to fetch and manage
* paginated data from the backend.
*
* Columns differ between modes:
* - `"bans"` — Time, IP, Service, Country, Jail, Ban Count.
* - `"accesses"` — Time, IP, Log Line, Country, Jail.
*/
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
MessageBar,
MessageBarBody,
Spinner,
Text,
Tooltip,
makeStyles,
tokens,
type TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { useBans, type BanTableMode } from "../hooks/useBans";
import type { AccessListItem, DashboardBanItem, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props for the {@link BanTable} component. */
interface BanTableProps {
/** Whether to render ban records or individual access events. */
mode: BanTableMode;
/**
* Active time-range preset — controlled by the parent `DashboardPage`.
* Changing this value triggers a re-fetch.
*/
timeRange: TimeRange;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
minHeight: "300px",
},
centred: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: tokens.spacingVerticalXXL,
},
tableWrapper: {
overflowX: "auto",
},
pagination: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: tokens.spacingHorizontalS,
paddingTop: tokens.spacingVerticalS,
},
mono: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
},
truncate: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "280px",
display: "inline-block",
},
countBadge: {
fontVariantNumeric: "tabular-nums",
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp for display.
*
* @param iso - ISO 8601 UTC string.
* @returns Localised date+time string.
*/
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/** Columns for the ban-list view (`mode === "bans"`). */
function buildBanColumns(styles: ReturnType<typeof useStyles>): TableColumnDefinition<DashboardBanItem>[] {
return [
createTableColumn<DashboardBanItem>({
columnId: "banned_at",
renderHeaderCell: () => "Time of Ban",
renderCell: (item) => (
<Text size={200}>{formatTimestamp(item.banned_at)}</Text>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: (item) => (
<span className={styles.mono}>{item.ip}</span>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "service",
renderHeaderCell: () => "Service / URL",
renderCell: (item) =>
item.service ? (
<Tooltip content={item.service} relationship="description">
<span className={`${styles.mono} ${styles.truncate}`}>{item.service}</span>
</Tooltip>
) : (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (item) => (
<Text size={200}>
{item.country_name ?? item.country_code ?? "—"}
</Text>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
createTableColumn<DashboardBanItem>({
columnId: "ban_count",
renderHeaderCell: () => "Bans",
renderCell: (item) => (
<Badge
appearance={item.ban_count > 1 ? "filled" : "outline"}
color={item.ban_count > 5 ? "danger" : item.ban_count > 1 ? "warning" : "informative"}
className={styles.countBadge}
>
{item.ban_count}
</Badge>
),
}),
];
}
/** Columns for the access-list view (`mode === "accesses"`). */
function buildAccessColumns(styles: ReturnType<typeof useStyles>): TableColumnDefinition<AccessListItem>[] {
return [
createTableColumn<AccessListItem>({
columnId: "timestamp",
renderHeaderCell: () => "Timestamp",
renderCell: (item) => (
<Text size={200}>{formatTimestamp(item.timestamp)}</Text>
),
}),
createTableColumn<AccessListItem>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: (item) => (
<span className={styles.mono}>{item.ip}</span>
),
}),
createTableColumn<AccessListItem>({
columnId: "line",
renderHeaderCell: () => "Log Line",
renderCell: (item) => (
<Tooltip content={item.line} relationship="description">
<span className={`${styles.mono} ${styles.truncate}`}>{item.line}</span>
</Tooltip>
),
}),
createTableColumn<AccessListItem>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (item) => (
<Text size={200}>
{item.country_name ?? item.country_code ?? "—"}
</Text>
),
}),
createTableColumn<AccessListItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Data table for the dashboard ban-list and access-list views.
*
* @param props.mode - `"bans"` or `"accesses"`.
* @param props.timeRange - Active time-range preset from the parent page.
*/
export function BanTable({ mode, timeRange }: BanTableProps): React.JSX.Element {
const styles = useStyles();
const { banItems, accessItems, total, page, setPage, loading, error } = useBans(
mode,
timeRange,
);
const banColumns = buildBanColumns(styles);
const accessColumns = buildAccessColumns(styles);
// --------------------------------------------------------------------------
// Loading state
// --------------------------------------------------------------------------
if (loading) {
return (
<div className={styles.centred}>
<Spinner label="Loading…" />
</div>
);
}
// --------------------------------------------------------------------------
// Error state
// --------------------------------------------------------------------------
if (error) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
// --------------------------------------------------------------------------
// Empty state
// --------------------------------------------------------------------------
const isEmpty = mode === "bans" ? banItems.length === 0 : accessItems.length === 0;
if (isEmpty) {
return (
<div className={styles.centred}>
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
No {mode === "bans" ? "bans" : "accesses"} recorded in the selected time window.
</Text>
</div>
);
}
// --------------------------------------------------------------------------
// Pagination helpers
// --------------------------------------------------------------------------
const pageSize = 100;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 1;
const hasNext = page < totalPages;
// --------------------------------------------------------------------------
// Render — bans mode
// --------------------------------------------------------------------------
if (mode === "bans") {
return (
<div className={styles.root}>
<div className={styles.tableWrapper}>
<DataGrid
items={banItems}
columns={banColumns}
getRowId={(item: DashboardBanItem) => `${item.ip}:${item.jail}:${item.banned_at}`}
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<DashboardBanItem>>
{({ item, rowId }) => (
<DataGridRow<DashboardBanItem> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
<div className={styles.pagination}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
{total} total · Page {page} of {totalPages}
</Text>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
disabled={!hasPrev}
onClick={() => { setPage(page - 1); }}
aria-label="Previous page"
/>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
disabled={!hasNext}
onClick={() => { setPage(page + 1); }}
aria-label="Next page"
/>
</div>
</div>
);
}
// --------------------------------------------------------------------------
// Render — accesses mode
// --------------------------------------------------------------------------
return (
<div className={styles.root}>
<div className={styles.tableWrapper}>
<DataGrid
items={accessItems}
columns={accessColumns}
getRowId={(item: AccessListItem) => `${item.ip}:${item.jail}:${item.timestamp}:${item.line.slice(0, 40)}`}
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<AccessListItem>>
{({ item, rowId }) => (
<DataGridRow<AccessListItem> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
<div className={styles.pagination}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
{total} total · Page {page} of {totalPages}
</Text>
<Button
icon={<ChevronLeftRegular />}
appearance="subtle"
disabled={!hasPrev}
onClick={() => { setPage(page - 1); }}
aria-label="Previous page"
/>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
disabled={!hasNext}
onClick={() => { setPage(page + 1); }}
aria-label="Next page"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
/**
* `useBans` hook.
*
* Fetches and manages paginated ban-list or access-list data from the
* dashboard endpoints. Re-fetches automatically when `timeRange` or `page`
* changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchAccesses, fetchBans } from "../api/dashboard";
import type { AccessListItem, DashboardBanItem, TimeRange } from "../types/ban";
/** The dashboard view mode: aggregate bans or individual access events. */
export type BanTableMode = "bans" | "accesses";
/** Items per page for the ban/access tables. */
const PAGE_SIZE = 100;
/** Return value shape for {@link useBans}. */
export interface UseBansResult {
/** Ban items — populated when `mode === "bans"`, otherwise empty. */
banItems: DashboardBanItem[];
/** Access items — populated when `mode === "accesses"`, otherwise empty. */
accessItems: AccessListItem[];
/** Total records in the selected time window (for pagination). */
total: number;
/** Current 1-based page number. */
page: number;
/** Navigate to a specific page. */
setPage: (p: number) => void;
/** Whether a fetch is currently in flight. */
loading: boolean;
/** Error message if the last fetch failed, otherwise `null`. */
error: string | null;
/** Imperatively re-fetch the current page. */
refresh: () => void;
}
/**
* Fetch and manage dashboard ban-list or access-list data.
*
* Automatically re-fetches when `mode`, `timeRange`, or `page` changes.
*
* @param mode - `"bans"` for the ban-list view; `"accesses"` for the
* access-list view.
* @param timeRange - Time-range preset that controls how far back to look.
* @returns Current data, pagination state, loading flag, and a `refresh`
* callback.
*/
export function useBans(mode: BanTableMode, timeRange: TimeRange): UseBansResult {
const [banItems, setBanItems] = useState<DashboardBanItem[]>([]);
const [accessItems, setAccessItems] = useState<AccessListItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [page, setPage] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Reset page when mode or time range changes.
useEffect(() => {
setPage(1);
}, [mode, timeRange]);
const doFetch = useCallback(async (): Promise<void> => {
setLoading(true);
setError(null);
try {
if (mode === "bans") {
const data = await fetchBans(timeRange, page, PAGE_SIZE);
setBanItems(data.items);
setAccessItems([]);
setTotal(data.total);
} else {
const data = await fetchAccesses(timeRange, page, PAGE_SIZE);
setAccessItems(data.items);
setBanItems([]);
setTotal(data.total);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to fetch data");
} finally {
setLoading(false);
}
}, [mode, timeRange, page]);
// Stable ref to the latest doFetch so the refresh callback is always current.
const doFetchRef = useRef(doFetch);
doFetchRef.current = doFetch;
useEffect(() => {
void doFetch();
}, [doFetch]);
const refresh = useCallback((): void => {
void doFetchRef.current();
}, []);
return {
banItems,
accessItems,
total,
page,
setPage,
loading,
error,
refresh,
};
}

View File

@@ -1,12 +1,31 @@
/**
* Dashboard page.
*
* Shows the fail2ban server status bar at the top.
* Full ban-list implementation is delivered in Stage 5.
* Composes the fail2ban server status bar at the top, a shared time-range
* selector, and two tabs: "Ban List" (aggregate bans) and "Access List"
* (individual matched log lines). The time-range selection is shared
* between both tabs so users can compare data for the same period.
*/
import { Text, makeStyles, tokens } from "@fluentui/react-components";
import { useState } from "react";
import {
Tab,
TabList,
Text,
ToggleButton,
Toolbar,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { BanTable } from "../components/BanTable";
import { ServerStatusBar } from "../components/ServerStatusBar";
import type { TimeRange } from "../types/ban";
import { TIME_RANGE_LABELS } from "../types/ban";
import type { BanTableMode } from "../hooks/useBans";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
@@ -14,22 +33,116 @@ const useStyles = makeStyles({
flexDirection: "column",
gap: tokens.spacingVerticalM,
},
section: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
sectionHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
tabContent: {
paddingTop: tokens.spacingVerticalS,
},
});
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Ordered time-range presets for the toolbar. */
const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Dashboard page — renders the server status bar and a Stage 5 placeholder.
* Main dashboard landing page.
*
* Displays the fail2ban server status, a time-range selector, and a
* tabbed view toggling between the ban list and the access list.
*/
export function DashboardPage(): JSX.Element {
export function DashboardPage(): React.JSX.Element {
const styles = useStyles();
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [activeTab, setActiveTab] = useState<BanTableMode>("bans");
return (
<div className={styles.root}>
{/* ------------------------------------------------------------------ */}
{/* Server status bar */}
{/* ------------------------------------------------------------------ */}
<ServerStatusBar />
<Text as="h1" size={700} weight="semibold">
Dashboard
</Text>
<Text as="p" size={300}>
Ban overview will be implemented in Stage 5.
</Text>
{/* ------------------------------------------------------------------ */}
{/* Ban / access list section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
{activeTab === "bans" ? "Ban List" : "Access List"}
</Text>
{/* Shared time-range selector */}
<Toolbar aria-label="Time range" size="small">
{TIME_RANGES.map((r) => (
<ToggleButton
key={r}
size="small"
checked={timeRange === r}
onClick={() => {
setTimeRange(r);
}}
aria-pressed={timeRange === r}
>
{TIME_RANGE_LABELS[r]}
</ToggleButton>
))}
</Toolbar>
</div>
{/* Tab switcher */}
<TabList
selectedValue={activeTab}
onTabSelect={(_, data) => {
setActiveTab(data.value as BanTableMode);
}}
size="small"
>
<Tab value="bans">Ban List</Tab>
<Tab value="accesses">Access List</Tab>
</TabList>
{/* Active tab content */}
<div className={styles.tabContent}>
<BanTable mode={activeTab} timeRange={timeRange} />
</div>
</div>
</div>
);
}

113
frontend/src/types/ban.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* TypeScript interfaces mirroring the backend ban Pydantic models.
*
* `backend/app/models/ban.py` — dashboard dashboard sections.
*/
// ---------------------------------------------------------------------------
// Time-range selector
// ---------------------------------------------------------------------------
/** The four supported time-range presets for dashboard views. */
export type TimeRange = "24h" | "7d" | "30d" | "365d";
/** Human-readable labels for each time-range preset. */
export const TIME_RANGE_LABELS: Record<TimeRange, string> = {
"24h": "Last 24 h",
"7d": "Last 7 days",
"30d": "Last 30 days",
"365d": "Last 365 days",
} as const;
// ---------------------------------------------------------------------------
// Ban-list table item
// ---------------------------------------------------------------------------
/**
* A single row in the dashboard ban-list table.
*
* Mirrors `DashboardBanItem` from `backend/app/models/ban.py`.
*/
export interface DashboardBanItem {
/** Banned IP address. */
ip: string;
/** Jail that issued the ban. */
jail: string;
/** ISO 8601 UTC timestamp of the ban. */
banned_at: string;
/** First matched log line (context for the ban), or null. */
service: string | null;
/** ISO 3166-1 alpha-2 country code, or null if unknown. */
country_code: string | null;
/** Human-readable country name, or null if unknown. */
country_name: string | null;
/** Autonomous System Number string, e.g. "AS3320", or null. */
asn: string | null;
/** Organisation name associated with the IP, or null. */
org: string | null;
/** How many times this IP was banned. */
ban_count: number;
}
/**
* Paginated ban-list response from `GET /api/dashboard/bans`.
*
* Mirrors `DashboardBanListResponse` from `backend/app/models/ban.py`.
*/
export interface DashboardBanListResponse {
/** Ban items for the current page. */
items: DashboardBanItem[];
/** Total number of bans in the selected time window. */
total: number;
/** Current 1-based page number. */
page: number;
/** Maximum items per page. */
page_size: number;
}
// ---------------------------------------------------------------------------
// Access-list table item
// ---------------------------------------------------------------------------
/**
* A single row in the dashboard access-list table.
*
* Each row represents one matched log line (failure attempt) that
* contributed to a ban.
*
* Mirrors `AccessListItem` from `backend/app/models/ban.py`.
*/
export interface AccessListItem {
/** IP address of the access event. */
ip: string;
/** Jail that recorded the access. */
jail: string;
/** ISO 8601 UTC timestamp of the ban that captured this access. */
timestamp: string;
/** Raw matched log line. */
line: string;
/** ISO 3166-1 alpha-2 country code, or null. */
country_code: string | null;
/** Human-readable country name, or null. */
country_name: string | null;
/** ASN string, or null. */
asn: string | null;
/** Organisation name, or null. */
org: string | null;
}
/**
* Paginated access-list response from `GET /api/dashboard/accesses`.
*
* Mirrors `AccessListResponse` from `backend/app/models/ban.py`.
*/
export interface AccessListResponse {
/** Access items for the current page. */
items: AccessListItem[];
/** Total number of access events in the selected window. */
total: number;
/** Current 1-based page number. */
page: number;
/** Maximum items per page. */
page_size: number;
}