Files
BanGUI/frontend/src/components/BanTable.tsx

275 lines
8.6 KiB
TypeScript

/**
* `BanTable` component.
*
* Renders a Fluent UI v9 `DataGrid` for the dashboard ban-list view.
* Uses the {@link useBans} hook to fetch and manage paginated data from
* the backend.
*
* Columns: Time, IP, Service, Country, Jail, Ban Count.
*/
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Text,
Tooltip,
makeStyles,
tokens,
type TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
import { useBans } from "../hooks/useBans";
import { formatTimestamp } from "../utils/formatDate";
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props for the {@link BanTable} component. */
interface BanTableProps {
/**
* Active time-range preset — controlled by the parent `DashboardPage`.
* Changing this value triggers a re-fetch.
*/
timeRange: TimeRange;
/**
* Active origin filter — controlled by the parent `DashboardPage`.
* Changing this value triggers a re-fetch and resets to page 1.
*/
origin?: BanOriginFilter;
}
// ---------------------------------------------------------------------------
// 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",
},
});
// ---------------------------------------------------------------------------
// 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) =>
item.country_name ?? item.country_code ? (
<Text size={200}>{item.country_name ?? item.country_code}</Text>
) : (
<Tooltip
content="Country could not be resolved — will retry automatically."
relationship="description"
>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
</Text>
</Tooltip>
),
}),
createTableColumn<DashboardBanItem>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (item) => <Text size={200}>{item.jail}</Text>,
}),
createTableColumn<DashboardBanItem>({
columnId: "origin",
renderHeaderCell: () => "Origin",
renderCell: (item) => (
<Badge
appearance="tint"
color={item.origin === "blocklist" ? "brand" : "informative"}
>
{item.origin === "blocklist" ? "Blocklist" : "Selfblock"}
</Badge>
),
}),
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>
),
}),
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Data table for the dashboard ban-list view.
*
* @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 {
const styles = useStyles();
const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin);
const banColumns = buildBanColumns(styles);
// --------------------------------------------------------------------------
// Loading state
// --------------------------------------------------------------------------
if (loading) {
return <PageLoading label="Loading…" />;
}
// --------------------------------------------------------------------------
// Error state
// --------------------------------------------------------------------------
if (error) {
return <PageError message={error} onRetry={refresh} />;
}
// --------------------------------------------------------------------------
// Empty state
// --------------------------------------------------------------------------
if (banItems.length === 0) {
return <PageEmpty message="No bans recorded in the selected time window." />;
}
// --------------------------------------------------------------------------
// Pagination helpers
// --------------------------------------------------------------------------
const pageSize = 100;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 1;
const hasNext = page < totalPages;
// --------------------------------------------------------------------------
// Render
// --------------------------------------------------------------------------
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>
);
}