275 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|