Replace inline frontend styles with makeStyles and design tokens
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
23
frontend/src/components/ChartTooltip.tsx
Normal file
23
frontend/src/components/ChartTooltip.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||
const { status, loading } = useSetup();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
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 className={styles.loadingWrapper}>
|
||||
<Spinner size="large" label="Loading…" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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,15 +30,46 @@ import { useJailsPageStyles } from "./jailsPageStyles";
|
||||
import { useJails } from "../../hooks/useJails";
|
||||
import type { JailSummary } from "../../types/jail";
|
||||
|
||||
const jailColumns = [
|
||||
const useOverviewStyles = makeStyles({
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
},
|
||||
jailName: {
|
||||
fontFamily: tokens.fontFamilyMonospace,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
badgeCount: {
|
||||
marginLeft: tokens.spacingHorizontalXS,
|
||||
},
|
||||
statusActionGroup: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalS,
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export function JailOverviewSection(): React.JSX.Element {
|
||||
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);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
const jailColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
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 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),
|
||||
@@ -64,17 +97,13 @@ const jailColumns = [
|
||||
{
|
||||
columnId: "banned",
|
||||
renderHeaderCell: () => "Banned",
|
||||
renderCell: (j: JailSummary) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||
),
|
||||
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>
|
||||
),
|
||||
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),
|
||||
},
|
||||
{
|
||||
@@ -95,20 +124,9 @@ const jailColumns = [
|
||||
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 sectionStyles = useCommonSectionStyles();
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = useJails();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
setOpError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
],
|
||||
[overviewStyles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={sectionStyles.section}>
|
||||
@@ -116,12 +134,12 @@ export function JailOverviewSection(): React.JSX.Element {
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user