Finish Task 13: extract remaining page subcomponents and clean page files
This commit is contained in:
203
frontend/src/pages/history/IpDetailView.tsx
Normal file
203
frontend/src/pages/history/IpDetailView.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellLayout,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowCounterclockwiseRegular, ArrowLeftRegular } from "@fluentui/react-icons";
|
||||
import { useCardStyles } from "../../theme/commonStyles";
|
||||
import { useIpHistory } from "../../hooks/useHistory";
|
||||
|
||||
interface IpDetailViewProps {
|
||||
ip: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalL,
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
monoText: {
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: "0.85rem",
|
||||
},
|
||||
detailGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
||||
gap: tokens.spacingVerticalM,
|
||||
padding: tokens.spacingVerticalM,
|
||||
marginBottom: tokens.spacingVerticalM,
|
||||
},
|
||||
detailField: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXS,
|
||||
},
|
||||
detailLabel: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontSize: "0.75rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: "0.9rem",
|
||||
fontWeight: "600",
|
||||
},
|
||||
tableWrapper: {
|
||||
overflow: "auto",
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
},
|
||||
});
|
||||
|
||||
export function IpDetailView({ ip, onBack }: IpDetailViewProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const cardStyles = useCardStyles();
|
||||
const { detail, loading, error, refresh } = useIpHistory(ip);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: tokens.spacingVerticalXL }}>
|
||||
<Spinner label={`Loading history for ${ip}…`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<MessageBar intent="warning">
|
||||
<MessageBarBody>No history found for {ip}.</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<Button icon={<ArrowLeftRegular />} appearance="subtle" onClick={onBack}>
|
||||
Back to list
|
||||
</Button>
|
||||
<Text as="h2" size={600} weight="semibold" className={styles.monoText}>
|
||||
{ip}
|
||||
</Text>
|
||||
</div>
|
||||
<Button icon={<ArrowCounterclockwiseRegular />} appearance="subtle" onClick={refresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={`${cardStyles.card} ${styles.detailGrid}`}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Total Bans</span>
|
||||
<span className={styles.detailValue}>{String(detail.total_bans)}</span>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Total Failures</span>
|
||||
<span className={styles.detailValue}>{String(detail.total_failures)}</span>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Last Banned</span>
|
||||
<span className={styles.detailValue}>
|
||||
{detail.last_ban_at ? new Date(detail.last_ban_at).toLocaleString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Country</span>
|
||||
<span className={styles.detailValue}>{detail.country_name ?? detail.country_code ?? "—"}</span>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>ASN</span>
|
||||
<span className={styles.detailValue}>{detail.asn ?? "—"}</span>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Organisation</span>
|
||||
<span className={styles.detailValue}>{detail.org ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text weight="semibold" size={400}>
|
||||
Ban Timeline ({String(detail.timeline.length)} events)
|
||||
</Text>
|
||||
|
||||
<div className={styles.tableWrapper}>
|
||||
<Table size="small" aria-label="Ban timeline">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Banned At</TableHeaderCell>
|
||||
<TableHeaderCell>Jail</TableHeaderCell>
|
||||
<TableHeaderCell>Failures</TableHeaderCell>
|
||||
<TableHeaderCell>Times Banned</TableHeaderCell>
|
||||
<TableHeaderCell>Matched Lines</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.timeline.map((event) => (
|
||||
<TableRow key={`${event.jail}-${event.banned_at}`}>
|
||||
<TableCell>
|
||||
<TableCellLayout>{new Date(event.banned_at).toLocaleString()}</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>{event.jail}</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>{String(event.failures)}</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>{String(event.ban_count)}</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout>
|
||||
{event.matches.length === 0 ? (
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
—
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
size={100}
|
||||
style={{
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{event.matches.join("\n")}
|
||||
</Text>
|
||||
)}
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user