Replace inline frontend styles with makeStyles and design tokens

This commit is contained in:
2026-04-19 12:04:24 +02:00
parent 91269448d0
commit d99d6bd119
16 changed files with 344 additions and 265 deletions

View File

@@ -349,6 +349,8 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
**Docs changes needed:** None.
**Status:** Completed.
**Why this is needed:** Inline styles bypass Griffel's atomic CSS engine, produce non-deduplicatable style nodes, and cannot respond to theme changes. Hardcoded pixel values break when the user switches to a different Fluent UI theme with different spacing scales. The project rule states that `makeStyles` with design tokens is the only permitted styling mechanism.
---

View File

@@ -0,0 +1,23 @@
import { makeStyles, tokens } from "@fluentui/react-components";
const useStyles = makeStyles({
tooltip: {
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
borderRadius: tokens.borderRadiusSmall,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase200,
},
});
interface ChartTooltipProps {
active: boolean;
children: React.ReactNode;
}
export function ChartTooltip({ active, children }: ChartTooltipProps): React.JSX.Element | null {
const styles = useStyles();
if (!active) return null;
return <div className={styles.tooltip}>{children}</div>;
}

View File

@@ -16,10 +16,7 @@ import {
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
tokens,
makeStyles,
} from "@fluentui/react-components";
import { makeStyles } from "@fluentui/react-components";
import {
CHART_AXIS_TEXT_TOKEN,
CHART_GRID_LINE_TOKEN,
@@ -27,6 +24,7 @@ import {
resolveFluentToken,
} from "../utils/chartTheme";
import { ChartStateWrapper } from "./ChartStateWrapper";
import { ChartTooltip } from "./ChartTooltip";
import { useJailDistribution } from "../hooks/useJailDistribution";
import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -69,6 +67,10 @@ interface BarEntry {
// Styles
// ---------------------------------------------------------------------------
const CHART_TICK_FONT_SIZE = 12;
const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 8 };
const Y_AXIS_WIDTH = 160;
const useStyles = makeStyles({
wrapper: {
width: "100%",
@@ -106,20 +108,11 @@ function JailTooltip(props: TooltipContentProps): React.JSX.Element | null {
const { fullName, value } = entry.payload as BarEntry;
return (
<div
style={{
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
borderRadius: "4px",
padding: "8px 12px",
color: resolveFluentToken(tokens.colorNeutralForeground1),
fontSize: "13px",
}}
>
<ChartTooltip active={active}>
<strong>{fullName}</strong>
<br />
{String(value)} ban{value === 1 ? "" : "s"}
</div>
</ChartTooltip>
);
}
@@ -162,20 +155,20 @@ export function JailDistributionChart({
<BarChart
layout="vertical"
data={entries}
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
margin={CHART_MARGIN}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
<XAxis
type="number"
tick={{ fill: axisColour, fontSize: 12 }}
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
axisLine={{ stroke: gridColour }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="name"
width={160}
tick={{ fill: axisColour, fontSize: 12 }}
width={Y_AXIS_WIDTH}
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
axisLine={false}
tickLine={false}
/>

View File

@@ -7,7 +7,7 @@
*/
import { Navigate } from "react-router-dom";
import { Spinner } from "@fluentui/react-components";
import { Spinner, makeStyles } from "@fluentui/react-components";
import { useSetup } from "../hooks/useSetup";
/**
@@ -24,19 +24,22 @@ interface SetupGuardProps {
*
* Redirects to `/setup` if setup is still pending.
*/
const useStyles = makeStyles({
loadingWrapper: {
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
},
});
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
const styles = useStyles();
const { status, loading } = useSetup();
if (loading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<div className={styles.loadingWrapper}>
<Spinner size="large" label="Loading…" />
</div>
);

View File

@@ -20,6 +20,7 @@ import {
CHART_GRID_LINE_TOKEN,
resolveFluentToken,
} from "../utils/chartTheme";
import { ChartTooltip } from "./ChartTooltip";
// ---------------------------------------------------------------------------
// Constants
@@ -60,6 +61,10 @@ interface BarEntry {
// Styles
// ---------------------------------------------------------------------------
const CHART_TICK_FONT_SIZE = 12;
const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 8 };
const Y_AXIS_WIDTH = 140;
const useStyles = makeStyles({
wrapper: {
width: "100%",
@@ -106,30 +111,18 @@ function buildEntries(
// Custom tooltip
// ---------------------------------------------------------------------------
function BarTooltip(
props: TooltipContentProps,
): React.JSX.Element | null {
function BarTooltip(props: TooltipContentProps): React.JSX.Element | null {
const { active, payload } = props;
if (!active || payload.length === 0) return null;
const entry = payload[0];
if (entry == null) return null;
// `fullName` is stored as an extra field on the payload item.
const fullName = (entry.payload as BarEntry).fullName;
return (
<div
style={{
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
borderRadius: "4px",
padding: "8px 12px",
color: resolveFluentToken(tokens.colorNeutralForeground1),
fontSize: "13px",
}}
>
<ChartTooltip active={active}>
<strong>{fullName}</strong>
<br />
{String(entry.value)} ban{entry.value === 1 ? "" : "s"}
</div>
</ChartTooltip>
);
}
@@ -171,20 +164,20 @@ export function TopCountriesBarChart({
<BarChart
layout="vertical"
data={entries}
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
margin={CHART_MARGIN}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
<XAxis
type="number"
tick={{ fill: axisColour, fontSize: 12 }}
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
axisLine={{ stroke: gridColour }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="name"
width={140}
tick={{ fill: axisColour, fontSize: 12 }}
width={Y_AXIS_WIDTH}
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
axisLine={false}
tickLine={false}
/>

View File

@@ -16,6 +16,7 @@ import type { LegendPayload } from "recharts/types/component/DefaultLegendConten
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles, Text } from "@fluentui/react-components";
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
import { ChartTooltip } from "./ChartTooltip";
// ---------------------------------------------------------------------------
// Constants
@@ -107,22 +108,13 @@ function PieTooltip(props: TooltipContentProps): React.JSX.Element | null {
const banCount = entry.value;
const displayName: string = entry.name?.toString() ?? "";
return (
<div
style={{
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
borderRadius: "4px",
padding: "8px 12px",
color: resolveFluentToken(tokens.colorNeutralForeground1),
fontSize: "13px",
}}
>
<ChartTooltip active={active}>
<strong>{displayName}</strong>
<br />
{banCount != null
? `${String(banCount)} ban${banCount === 1 ? "" : "s"}`
: ""}
</div>
</ChartTooltip>
);
}

View File

@@ -14,14 +14,9 @@ import {
useState,
} from "react";
import { Button, makeStyles, tokens } from "@fluentui/react-components";
import { geoMercator, geoPath, type GeoPath } from "d3-geo";
import { geoMercator, geoPath } from "d3-geo";
import { feature } from "topojson-client";
import type {
Feature,
FeatureCollection,
GeoJsonProperties,
Geometry,
} from "geojson";
import type { FeatureCollection } from "geojson";
import type {
GeometryCollection as TopoGeometryCollection,
Topology,
@@ -37,6 +32,7 @@ const MIN_ZOOM = 1;
const MAX_ZOOM = 8;
const ZOOM_STEP = 0.5;
const PAN_THRESHOLD = 3;
const MAP_COUNTRY_STROKE_WIDTH = 0.75;
const useStyles = makeStyles({
mapWrapper: {
@@ -52,7 +48,7 @@ const useStyles = makeStyles({
country: {
transition: "fill 150ms ease, stroke 150ms ease",
stroke: tokens.colorNeutralStroke2,
strokeWidth: 0.75,
strokeWidth: MAP_COUNTRY_STROKE_WIDTH,
fill: "var(--country-fill)",
outline: "none",
cursor: "pointer",
@@ -64,7 +60,7 @@ const useStyles = makeStyles({
fill: "var(--country-selected-fill)",
},
countLabel: {
fontSize: "9px",
fontSize: tokens.fontSizeBase200,
fontWeight: "600",
fill: tokens.colorNeutralForeground1,
pointerEvents: "none",
@@ -173,10 +169,7 @@ export function WorldMap({
const geoJson = useMemo(
() =>
feature(topology, topology.objects.countries) as FeatureCollection<
Geometry,
GeoJsonProperties
>,
feature(topology, topology.objects.countries) as FeatureCollection,
[topology],
);
@@ -185,13 +178,13 @@ export function WorldMap({
[geoJson],
);
const pathGenerator = useMemo<GeoPath<unknown, Feature<Geometry, GeoJsonProperties>>>(
const pathGenerator = useMemo(
() => geoPath().projection(projection),
[projection],
);
const countryFeatures = useMemo(
() => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null),
() => geoJson.features,
[geoJson.features],
);
@@ -331,7 +324,7 @@ export function WorldMap({
<svg
className={styles.svg}
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
viewBox={`0 0 ${String(MAP_WIDTH)} ${String(MAP_HEIGHT)}`}
role="img"
aria-label="World map showing banned IP counts by country."
onPointerDown={handlePointerDown}
@@ -341,7 +334,7 @@ export function WorldMap({
onWheel={handleWheel}
onClick={handleSvgClick}
>
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
<g transform={`translate(${String(center[0])} ${String(center[1])}) scale(${String(zoom)})`}>
{countryFeatures.map((featureItem) => {
const rawId = featureItem.id;
const numericId = String(Number(rawId));

View File

@@ -166,7 +166,7 @@ export function BlocklistScheduleSection({ onRunImport, runImportRunning }: Sche
</>
)}
<Button appearance="primary" onClick={handleSave} disabled={saving} style={{ alignSelf: "flex-end" }}>
<Button appearance="primary" className={styles.saveButton} onClick={handleSave} disabled={saving}>
{saving ? <Spinner size="tiny" /> : "Save Schedule"}
</Button>
</div>

View File

@@ -118,7 +118,7 @@ export function BlocklistSourcesSection({ onRunImport, runImportRunning }: Sourc
<Text size={500} weight="semibold">
Blocklist Sources
</Text>
<div style={{ display: "flex", gap: "8px" }}>
<div className={styles.headerActions}>
<Button icon={<PlayRegular />} appearance="secondary" onClick={onRunImport} disabled={runImportRunning}>
{runImportRunning ? <Spinner size="tiny" /> : "Run Now"}
</Button>

View File

@@ -11,6 +11,8 @@ import {
MessageBarBody,
Spinner,
Text,
makeStyles,
tokens,
} from "@fluentui/react-components";
import type { BlocklistSource, PreviewResponse } from "../../types/blocklist";
@@ -21,7 +23,25 @@ interface PreviewDialogProps {
fetchPreview: (id: number) => Promise<PreviewResponse>;
}
const useStyles = makeStyles({
centeredLoader: {
textAlign: "center",
padding: tokens.spacingVerticalL,
},
previewStack: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
previewEntries: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
});
export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
const styles = useStyles();
const [data, setData] = useState<PreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -49,7 +69,7 @@ export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDi
<DialogTitle>Preview {source?.name ?? ""}</DialogTitle>
<DialogContent>
{loading && (
<div style={{ textAlign: "center", padding: "16px" }}>
<div className={styles.centeredLoader}>
<Spinner label="Downloading…" />
</div>
)}
@@ -59,11 +79,11 @@ export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDi
</MessageBar>
)}
{data && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div className={styles.previewStack}>
<Text size={300}>
{data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}:
</Text>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div className={styles.previewEntries}>
{data.entries.map((entry) => (
<div key={entry}>{entry}</div>
))}

View File

@@ -12,6 +12,8 @@ import {
MessageBar,
MessageBarBody,
Switch,
makeStyles,
tokens,
} from "@fluentui/react-components";
interface SourceFormValues {
@@ -30,6 +32,14 @@ interface SourceFormDialogProps {
onSubmit: (values: SourceFormValues) => void;
}
const useStyles = makeStyles({
contentStack: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalL,
},
});
export function SourceFormDialog({
open,
mode,
@@ -39,6 +49,7 @@ export function SourceFormDialog({
onClose,
onSubmit,
}: SourceFormDialogProps): React.JSX.Element {
const styles = useStyles();
const [values, setValues] = useState<SourceFormValues>(initial);
const handleOpen = useCallback((): void => {
@@ -56,7 +67,7 @@ export function SourceFormDialog({
<DialogBody>
<DialogTitle>{mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"}</DialogTitle>
<DialogContent>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<div className={styles.contentStack}>
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>

View File

@@ -9,7 +9,8 @@ export const useBlocklistStyles = makeStyles({
tableWrapper: { overflowX: "auto" },
actionsCell: { display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" },
mono: { fontFamily: "Consolas, 'Courier New', monospace", fontSize: "12px" },
headerActions: { display: "flex", gap: tokens.spacingHorizontalS },
mono: { fontFamily: tokens.fontFamilyMonospace, fontSize: tokens.fontSizeBase200 },
centred: {
display: "flex",
justifyContent: "center",
@@ -22,13 +23,14 @@ export const useBlocklistStyles = makeStyles({
alignItems: "flex-end",
},
scheduleField: { minWidth: "140px" },
saveButton: { alignSelf: "flex-end" },
metaRow: {
display: "flex",
gap: tokens.spacingHorizontalL,
flexWrap: "wrap",
paddingTop: tokens.spacingVerticalS,
},
metaItem: { display: "flex", flexDirection: "column", gap: "2px" },
metaItem: { display: "flex", flexDirection: "column", gap: tokens.spacingHorizontalXXS },
runResult: {
display: "flex",
flexDirection: "column",

View File

@@ -14,6 +14,8 @@ import {
Button,
Spinner,
Text,
makeStyles,
mergeClasses,
tokens,
} from "@fluentui/react-components";
import { Checkmark16Regular } from "@fluentui/react-icons";
@@ -33,6 +35,32 @@ export interface AutoSaveIndicatorProps {
/** Fade-out delay after "saved" status in milliseconds. */
const SAVED_FADE_DELAY_MS = 2000;
const useStyles = makeStyles({
root: {
display: "inline-flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
minWidth: "80px",
},
savingText: {
color: tokens.colorNeutralForeground2,
},
errorText: {
color: tokens.colorPaletteRedForeground3,
},
savedMotion: {
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
animationName: "fadeInScale",
animationDuration: tokens.durationFast,
animationTimingFunction: tokens.curveDecelerateMid,
animationFillMode: "both",
},
savedHidden: {
opacity: 0,
transform: "scale(0.95)",
},
});
/**
* Compact inline indicator for auto-save state.
*
@@ -44,6 +72,7 @@ export function AutoSaveIndicator({
errorText,
onRetry,
}: AutoSaveIndicatorProps): React.JSX.Element {
const styles = useStyles();
const [fadingOut, setFadingOut] = useState(false);
// Trigger the fade-out transition 2 s after the saved state is reached.
@@ -62,20 +91,11 @@ export function AutoSaveIndicator({
// Always render the aria-live region so screen readers track changes.
return (
<span
aria-live="polite"
role="status"
style={{
display: "inline-flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
minWidth: 80,
}}
>
<span aria-live="polite" role="status" className={styles.root}>
{status === "saving" && (
<>
<Spinner size="extra-tiny" />
<Text size={200} style={{ color: tokens.colorNeutralForeground2 }}>
<Text size={200} className={styles.savingText}>
Saving
</Text>
</>
@@ -83,15 +103,10 @@ export function AutoSaveIndicator({
{status === "saved" && (
<span
style={{
opacity: fadingOut ? 0 : 1,
transform: fadingOut ? "scale(0.95)" : "scale(1)",
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
animationName: fadingOut ? undefined : "fadeInScale",
animationDuration: tokens.durationFast,
animationTimingFunction: tokens.curveDecelerateMid,
animationFillMode: "both",
}}
className={mergeClasses(
styles.savedMotion,
fadingOut ? styles.savedHidden : undefined,
)}
>
<Badge
appearance="tint"
@@ -106,10 +121,7 @@ export function AutoSaveIndicator({
{status === "error" && (
<>
<Text
size={200}
style={{ color: tokens.colorPaletteRedForeground3 }}
>
<Text size={200} className={styles.errorText}>
{errorText ?? "Save failed."}
</Text>
{onRetry && (

View File

@@ -17,6 +17,7 @@ import {
MessageBarBody,
Spinner,
Textarea,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
@@ -37,6 +38,37 @@ export interface RawConfigSectionProps {
/** Minimum visible rows for the monospace text area. */
const MIN_ROWS = 15;
const useStyles = makeStyles({
headerText: {
fontWeight: tokens.fontWeightSemibold,
},
loadingRow: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
padding: tokens.spacingVerticalM,
},
loadingText: {
color: tokens.colorNeutralForeground3,
},
textArea: {
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
width: "100%",
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
marginBottom: tokens.spacingVerticalS,
},
controlsRow: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
marginTop: tokens.spacingVerticalXS,
},
errorBar: {
marginBottom: tokens.spacingVerticalS,
},
});
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -52,6 +84,7 @@ export function RawConfigSection({
saveContent,
label = "Raw Configuration",
}: RawConfigSectionProps): React.JSX.Element {
const styles = useStyles();
/** Raw text content; null means not yet loaded. */
const [content, setContent] = useState<string | null>(null);
const [localText, setLocalText] = useState("");
@@ -122,27 +155,20 @@ export function RawConfigSection({
<Accordion collapsible onToggle={handleExpand}>
<AccordionItem value="raw">
<AccordionHeader>
<span style={{ fontWeight: tokens.fontWeightSemibold }}>{label}</span>
<span className={styles.headerText}>{label}</span>
</AccordionHeader>
<AccordionPanel>
{fetchLoading && (
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
padding: tokens.spacingVerticalM,
}}
>
<div className={styles.loadingRow}>
<Spinner size="tiny" />
<span style={{ color: tokens.colorNeutralForeground3 }}>
<span className={styles.loadingText}>
Loading
</span>
</div>
)}
{fetchError && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBar intent="error" className={styles.errorBar}>
<MessageBarBody>{fetchError}</MessageBarBody>
</MessageBar>
)}
@@ -156,23 +182,10 @@ export function RawConfigSection({
}}
resize="vertical"
rows={MIN_ROWS}
style={{
fontFamily: "monospace",
fontSize: "0.85rem",
width: "100%",
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
marginBottom: tokens.spacingVerticalS,
}}
className={styles.textArea}
aria-label={label}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalS,
marginTop: tokens.spacingVerticalXS,
}}
>
<div className={styles.controlsRow}>
<Button
appearance="primary"
onClick={() => {

View File

@@ -9,6 +9,7 @@
* remains fast even when a jail contains thousands of banned IPs.
*/
import { useMemo } from "react";
import {
Badge,
Button,
@@ -102,10 +103,19 @@ const useStyles = makeStyles({
alignItems: "center",
gap: tokens.spacingHorizontalXS,
},
rowsPerPageDropdown: {
minWidth: "80px",
},
mono: {
fontFamily: "Consolas, 'Courier New', monospace",
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
emptyValue: {
color: tokens.colorNeutralForeground4,
},
});
// ---------------------------------------------------------------------------
@@ -118,64 +128,6 @@ interface BanRow {
onUnban: (ip: string) => Promise<void>;
}
const columns: TableColumnDefinition<BanRow>[] = [
createTableColumn<BanRow>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: ({ ban }) => (
<Text
style={{
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
}}
>
{ban.ip}
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: ({ ban }) =>
ban.country ? (
<Text size={200}>{ban.country}</Text>
) : (
<Text size={200} style={{ color: tokens.colorNeutralForeground4 }}>
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: ({ ban }) => (
<Text size={200}>{ban.banned_at ? formatTimestamp(ban.banned_at) : "—"}</Text>
),
}),
createTableColumn<BanRow>({
columnId: "expires_at",
renderHeaderCell: () => "Expires At",
renderCell: ({ ban }) => (
<Text size={200}>{ban.expires_at ? formatTimestamp(ban.expires_at) : "—"}</Text>
),
}),
createTableColumn<BanRow>({
columnId: "actions",
renderHeaderCell: () => "",
renderCell: ({ ban, onUnban }) => (
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => { void onUnban(ban.ip); }}
aria-label={`Unban ${ban.ip}`}
/>
</Tooltip>
),
}),
];
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
@@ -224,6 +176,58 @@ export function BannedIpsSection({
const styles = useStyles();
const sectionStyles = useCommonSectionStyles();
const columns: TableColumnDefinition<BanRow>[] = useMemo(
() => [
createTableColumn<BanRow>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: ({ ban }) => <Text className={styles.mono}>{ban.ip}</Text>,
}),
createTableColumn<BanRow>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: ({ ban }) =>
ban.country ? (
<Text size={200}>{ban.country}</Text>
) : (
<Text size={200} className={styles.emptyValue}>
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: ({ ban }) => (
<Text size={200}>{ban.banned_at ? formatTimestamp(ban.banned_at) : "—"}</Text>
),
}),
createTableColumn<BanRow>({
columnId: "expires_at",
renderHeaderCell: () => "Expires At",
renderCell: ({ ban }) => (
<Text size={200}>{ban.expires_at ? formatTimestamp(ban.expires_at) : "—"}</Text>
),
}),
createTableColumn<BanRow>({
columnId: "actions",
renderHeaderCell: () => "",
renderCell: ({ ban, onUnban }) => (
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => { void onUnban(ban.ip); }}
aria-label={`Unban ${ban.ip}`}
/>
</Tooltip>
),
}),
],
[styles],
);
const rows: BanRow[] = items.map((ban) => ({
ban,
onUnban,
@@ -286,7 +290,7 @@ export function BannedIpsSection({
</div>
) : items.length === 0 ? (
<div className={styles.centred}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
<Text size={200} className={styles.emptyText}>
No IPs currently banned in this jail.
</Text>
</div>
@@ -334,7 +338,7 @@ export function BannedIpsSection({
onPageChange(1);
}
}}
style={{ minWidth: "80px" }}
className={styles.rowsPerPageDropdown}
>
{PAGE_SIZE_OPTIONS.map((n) => (
<Option key={n} value={String(n)}>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { formatSeconds } from "../../utils/formatDate";
import {
@@ -15,6 +15,8 @@ import {
Spinner,
Text,
Tooltip,
makeStyles,
tokens,
} from "@fluentui/react-components";
import {
ArrowClockwiseRegular,
@@ -28,77 +30,27 @@ import { useJailsPageStyles } from "./jailsPageStyles";
import { useJails } from "../../hooks/useJails";
import type { JailSummary } from "../../types/jail";
const jailColumns = [
{
columnId: "name",
renderHeaderCell: () => "Jail",
renderCell: (j: JailSummary) => (
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
{j.name}
</Text>
</Link>
),
compare: (a: JailSummary, b: JailSummary) => a.name.localeCompare(b.name),
const useOverviewStyles = makeStyles({
link: {
textDecoration: "none",
},
{
columnId: "status",
renderHeaderCell: () => "Status",
renderCell: (j: JailSummary) => {
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
return <Badge appearance="filled" color="success">running</Badge>;
},
compare: (a: JailSummary, b: JailSummary) => {
if (a.running !== b.running) return a.running ? -1 : 1;
if (a.idle !== b.idle) return a.idle ? 1 : -1;
return 0;
},
jailName: {
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
},
{
columnId: "backend",
renderHeaderCell: () => "Backend",
renderCell: (j: JailSummary) => <Text size={200}>{j.backend}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.backend.localeCompare(b.backend),
badgeCount: {
marginLeft: tokens.spacingHorizontalXS,
},
{
columnId: "banned",
renderHeaderCell: () => "Banned",
renderCell: (j: JailSummary) => (
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
),
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_banned ?? 0) - (b.status?.currently_banned ?? 0),
statusActionGroup: {
display: "flex",
gap: tokens.spacingHorizontalS,
alignItems: "center",
},
{
columnId: "failed",
renderHeaderCell: () => "Failed",
renderCell: (j: JailSummary) => (
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
),
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_failed ?? 0) - (b.status?.currently_failed ?? 0),
},
{
columnId: "findTime",
renderHeaderCell: () => "Find Time",
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.find_time)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.find_time - b.find_time,
},
{
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.ban_time)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.ban_time - b.ban_time,
},
{
columnId: "maxRetry",
renderHeaderCell: () => "Max Retry",
renderCell: (j: JailSummary) => <Text size={200}>{String(j.max_retry)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.max_retry - b.max_retry,
},
];
});
export function JailOverviewSection(): React.JSX.Element {
const styles = useJailsPageStyles();
const pageStyles = useJailsPageStyles();
const overviewStyles = useOverviewStyles();
const sectionStyles = useCommonSectionStyles();
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = useJails();
const [opError, setOpError] = useState<string | null>(null);
@@ -110,18 +62,84 @@ export function JailOverviewSection(): React.JSX.Element {
});
};
const jailColumns = useMemo(
() => [
{
columnId: "name",
renderHeaderCell: () => "Jail",
renderCell: (j: JailSummary) => (
<Link to={`/jails/${encodeURIComponent(j.name)}`} className={overviewStyles.link}>
<Text className={overviewStyles.jailName}>{j.name}</Text>
</Link>
),
compare: (a: JailSummary, b: JailSummary) => a.name.localeCompare(b.name),
},
{
columnId: "status",
renderHeaderCell: () => "Status",
renderCell: (j: JailSummary) => {
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
return <Badge appearance="filled" color="success">running</Badge>;
},
compare: (a: JailSummary, b: JailSummary) => {
if (a.running !== b.running) return a.running ? -1 : 1;
if (a.idle !== b.idle) return a.idle ? 1 : -1;
return 0;
},
},
{
columnId: "backend",
renderHeaderCell: () => "Backend",
renderCell: (j: JailSummary) => <Text size={200}>{j.backend}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.backend.localeCompare(b.backend),
},
{
columnId: "banned",
renderHeaderCell: () => "Banned",
renderCell: (j: JailSummary) => <Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>,
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_banned ?? 0) - (b.status?.currently_banned ?? 0),
},
{
columnId: "failed",
renderHeaderCell: () => "Failed",
renderCell: (j: JailSummary) => <Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>,
compare: (a: JailSummary, b: JailSummary) => (a.status?.currently_failed ?? 0) - (b.status?.currently_failed ?? 0),
},
{
columnId: "findTime",
renderHeaderCell: () => "Find Time",
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.find_time)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.find_time - b.find_time,
},
{
columnId: "banTime",
renderHeaderCell: () => "Ban Time",
renderCell: (j: JailSummary) => <Text size={200}>{formatSeconds(j.ban_time)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.ban_time - b.ban_time,
},
{
columnId: "maxRetry",
renderHeaderCell: () => "Max Retry",
renderCell: (j: JailSummary) => <Text size={200}>{String(j.max_retry)}</Text>,
compare: (a: JailSummary, b: JailSummary) => a.max_retry - b.max_retry,
},
],
[overviewStyles],
);
return (
<div className={sectionStyles.section}>
<div className={sectionStyles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Jail Overview
{total > 0 && (
<Badge appearance="tint" style={{ marginLeft: "8px" }}>
<Badge appearance="tint" className={overviewStyles.badgeCount}>
{String(total)}
</Badge>
)}
</Text>
<div className={styles.actionRow}>
<div className={pageStyles.actionRow}>
<Button
size="small"
appearance="subtle"
@@ -148,11 +166,11 @@ export function JailOverviewSection(): React.JSX.Element {
)}
{loading && jails.length === 0 ? (
<div className={styles.centred}>
<div className={pageStyles.centred}>
<Spinner label="Loading jails…" />
</div>
) : (
<div className={styles.tableWrapper}>
<div className={pageStyles.tableWrapper}>
<DataGrid items={jails} columns={jailColumns} getRowId={(j: JailSummary) => j.name} focusMode="composite">
<DataGridHeader>
<DataGridRow>
@@ -166,7 +184,7 @@ export function JailOverviewSection(): React.JSX.Element {
if (columnId === "status") {
return (
<DataGridCell>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<div className={overviewStyles.statusActionGroup}>
{renderCell(item)}
<Tooltip content={item.running ? "Stop jail" : "Start jail"} relationship="label">
<Button