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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user