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:
36
frontend/package-lock.json
generated
36
frontend/package-lock.json
generated
@@ -25,8 +25,10 @@
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"jiti": "^2.6.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
},
|
||||
@@ -4204,6 +4206,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -4884,6 +4896,30 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz",
|
||||
"integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/typescript-estree": "8.56.1",
|
||||
"@typescript-eslint/utils": "8.56.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"jiti": "^2.6.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
|
||||
394
frontend/src/components/BanTable.tsx
Normal file
394
frontend/src/components/BanTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/hooks/useBans.ts
Normal file
107
frontend/src/hooks/useBans.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
113
frontend/src/types/ban.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user