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.
|
**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.
|
**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,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||||
import {
|
import { makeStyles } from "@fluentui/react-components";
|
||||||
tokens,
|
|
||||||
makeStyles,
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import {
|
import {
|
||||||
CHART_AXIS_TEXT_TOKEN,
|
CHART_AXIS_TEXT_TOKEN,
|
||||||
CHART_GRID_LINE_TOKEN,
|
CHART_GRID_LINE_TOKEN,
|
||||||
@@ -27,6 +24,7 @@ import {
|
|||||||
resolveFluentToken,
|
resolveFluentToken,
|
||||||
} from "../utils/chartTheme";
|
} from "../utils/chartTheme";
|
||||||
import { ChartStateWrapper } from "./ChartStateWrapper";
|
import { ChartStateWrapper } from "./ChartStateWrapper";
|
||||||
|
import { ChartTooltip } from "./ChartTooltip";
|
||||||
import { useJailDistribution } from "../hooks/useJailDistribution";
|
import { useJailDistribution } from "../hooks/useJailDistribution";
|
||||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||||
|
|
||||||
@@ -69,6 +67,10 @@ interface BarEntry {
|
|||||||
// Styles
|
// 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({
|
const useStyles = makeStyles({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -106,20 +108,11 @@ function JailTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
|||||||
|
|
||||||
const { fullName, value } = entry.payload as BarEntry;
|
const { fullName, value } = entry.payload as BarEntry;
|
||||||
return (
|
return (
|
||||||
<div
|
<ChartTooltip active={active}>
|
||||||
style={{
|
|
||||||
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
|
|
||||||
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "8px 12px",
|
|
||||||
color: resolveFluentToken(tokens.colorNeutralForeground1),
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{fullName}</strong>
|
<strong>{fullName}</strong>
|
||||||
<br />
|
<br />
|
||||||
{String(value)} ban{value === 1 ? "" : "s"}
|
{String(value)} ban{value === 1 ? "" : "s"}
|
||||||
</div>
|
</ChartTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,20 +155,20 @@ export function JailDistributionChart({
|
|||||||
<BarChart
|
<BarChart
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
data={entries}
|
data={entries}
|
||||||
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
|
margin={CHART_MARGIN}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
tick={{ fill: axisColour, fontSize: 12 }}
|
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
|
||||||
axisLine={{ stroke: gridColour }}
|
axisLine={{ stroke: gridColour }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
width={160}
|
width={Y_AXIS_WIDTH}
|
||||||
tick={{ fill: axisColour, fontSize: 12 }}
|
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { Spinner } from "@fluentui/react-components";
|
import { Spinner, makeStyles } from "@fluentui/react-components";
|
||||||
import { useSetup } from "../hooks/useSetup";
|
import { useSetup } from "../hooks/useSetup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,19 +24,22 @@ interface SetupGuardProps {
|
|||||||
*
|
*
|
||||||
* Redirects to `/setup` if setup is still pending.
|
* 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 {
|
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
const { status, loading } = useSetup();
|
const { status, loading } = useSetup();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={styles.loadingWrapper}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
minHeight: "100vh",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spinner size="large" label="Loading…" />
|
<Spinner size="large" label="Loading…" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
CHART_GRID_LINE_TOKEN,
|
CHART_GRID_LINE_TOKEN,
|
||||||
resolveFluentToken,
|
resolveFluentToken,
|
||||||
} from "../utils/chartTheme";
|
} from "../utils/chartTheme";
|
||||||
|
import { ChartTooltip } from "./ChartTooltip";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -60,6 +61,10 @@ interface BarEntry {
|
|||||||
// Styles
|
// 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({
|
const useStyles = makeStyles({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -106,30 +111,18 @@ function buildEntries(
|
|||||||
// Custom tooltip
|
// Custom tooltip
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function BarTooltip(
|
function BarTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
||||||
props: TooltipContentProps,
|
|
||||||
): React.JSX.Element | null {
|
|
||||||
const { active, payload } = props;
|
const { active, payload } = props;
|
||||||
if (!active || payload.length === 0) return null;
|
if (!active || payload.length === 0) return null;
|
||||||
const entry = payload[0];
|
const entry = payload[0];
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
// `fullName` is stored as an extra field on the payload item.
|
|
||||||
const fullName = (entry.payload as BarEntry).fullName;
|
const fullName = (entry.payload as BarEntry).fullName;
|
||||||
return (
|
return (
|
||||||
<div
|
<ChartTooltip active={active}>
|
||||||
style={{
|
|
||||||
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
|
|
||||||
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "8px 12px",
|
|
||||||
color: resolveFluentToken(tokens.colorNeutralForeground1),
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{fullName}</strong>
|
<strong>{fullName}</strong>
|
||||||
<br />
|
<br />
|
||||||
{String(entry.value)} ban{entry.value === 1 ? "" : "s"}
|
{String(entry.value)} ban{entry.value === 1 ? "" : "s"}
|
||||||
</div>
|
</ChartTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,20 +164,20 @@ export function TopCountriesBarChart({
|
|||||||
<BarChart
|
<BarChart
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
data={entries}
|
data={entries}
|
||||||
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
|
margin={CHART_MARGIN}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
tick={{ fill: axisColour, fontSize: 12 }}
|
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
|
||||||
axisLine={{ stroke: gridColour }}
|
axisLine={{ stroke: gridColour }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
width={140}
|
width={Y_AXIS_WIDTH}
|
||||||
tick={{ fill: axisColour, fontSize: 12 }}
|
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { LegendPayload } from "recharts/types/component/DefaultLegendConten
|
|||||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||||
import { tokens, makeStyles, Text } from "@fluentui/react-components";
|
import { tokens, makeStyles, Text } from "@fluentui/react-components";
|
||||||
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
||||||
|
import { ChartTooltip } from "./ChartTooltip";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -107,22 +108,13 @@ function PieTooltip(props: TooltipContentProps): React.JSX.Element | null {
|
|||||||
const banCount = entry.value;
|
const banCount = entry.value;
|
||||||
const displayName: string = entry.name?.toString() ?? "";
|
const displayName: string = entry.name?.toString() ?? "";
|
||||||
return (
|
return (
|
||||||
<div
|
<ChartTooltip active={active}>
|
||||||
style={{
|
|
||||||
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
|
|
||||||
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "8px 12px",
|
|
||||||
color: resolveFluentToken(tokens.colorNeutralForeground1),
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{displayName}</strong>
|
<strong>{displayName}</strong>
|
||||||
<br />
|
<br />
|
||||||
{banCount != null
|
{banCount != null
|
||||||
? `${String(banCount)} ban${banCount === 1 ? "" : "s"}`
|
? `${String(banCount)} ban${banCount === 1 ? "" : "s"}`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</ChartTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,9 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
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 { feature } from "topojson-client";
|
||||||
import type {
|
import type { FeatureCollection } from "geojson";
|
||||||
Feature,
|
|
||||||
FeatureCollection,
|
|
||||||
GeoJsonProperties,
|
|
||||||
Geometry,
|
|
||||||
} from "geojson";
|
|
||||||
import type {
|
import type {
|
||||||
GeometryCollection as TopoGeometryCollection,
|
GeometryCollection as TopoGeometryCollection,
|
||||||
Topology,
|
Topology,
|
||||||
@@ -37,6 +32,7 @@ const MIN_ZOOM = 1;
|
|||||||
const MAX_ZOOM = 8;
|
const MAX_ZOOM = 8;
|
||||||
const ZOOM_STEP = 0.5;
|
const ZOOM_STEP = 0.5;
|
||||||
const PAN_THRESHOLD = 3;
|
const PAN_THRESHOLD = 3;
|
||||||
|
const MAP_COUNTRY_STROKE_WIDTH = 0.75;
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
mapWrapper: {
|
mapWrapper: {
|
||||||
@@ -52,7 +48,7 @@ const useStyles = makeStyles({
|
|||||||
country: {
|
country: {
|
||||||
transition: "fill 150ms ease, stroke 150ms ease",
|
transition: "fill 150ms ease, stroke 150ms ease",
|
||||||
stroke: tokens.colorNeutralStroke2,
|
stroke: tokens.colorNeutralStroke2,
|
||||||
strokeWidth: 0.75,
|
strokeWidth: MAP_COUNTRY_STROKE_WIDTH,
|
||||||
fill: "var(--country-fill)",
|
fill: "var(--country-fill)",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -64,7 +60,7 @@ const useStyles = makeStyles({
|
|||||||
fill: "var(--country-selected-fill)",
|
fill: "var(--country-selected-fill)",
|
||||||
},
|
},
|
||||||
countLabel: {
|
countLabel: {
|
||||||
fontSize: "9px",
|
fontSize: tokens.fontSizeBase200,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
fill: tokens.colorNeutralForeground1,
|
fill: tokens.colorNeutralForeground1,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
@@ -173,10 +169,7 @@ export function WorldMap({
|
|||||||
|
|
||||||
const geoJson = useMemo(
|
const geoJson = useMemo(
|
||||||
() =>
|
() =>
|
||||||
feature(topology, topology.objects.countries) as FeatureCollection<
|
feature(topology, topology.objects.countries) as FeatureCollection,
|
||||||
Geometry,
|
|
||||||
GeoJsonProperties
|
|
||||||
>,
|
|
||||||
[topology],
|
[topology],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,13 +178,13 @@ export function WorldMap({
|
|||||||
[geoJson],
|
[geoJson],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pathGenerator = useMemo<GeoPath<unknown, Feature<Geometry, GeoJsonProperties>>>(
|
const pathGenerator = useMemo(
|
||||||
() => geoPath().projection(projection),
|
() => geoPath().projection(projection),
|
||||||
[projection],
|
[projection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const countryFeatures = useMemo(
|
const countryFeatures = useMemo(
|
||||||
() => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null),
|
() => geoJson.features,
|
||||||
[geoJson.features],
|
[geoJson.features],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -331,7 +324,7 @@ export function WorldMap({
|
|||||||
|
|
||||||
<svg
|
<svg
|
||||||
className={styles.svg}
|
className={styles.svg}
|
||||||
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
viewBox={`0 0 ${String(MAP_WIDTH)} ${String(MAP_HEIGHT)}`}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="World map showing banned IP counts by country."
|
aria-label="World map showing banned IP counts by country."
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
@@ -341,7 +334,7 @@ export function WorldMap({
|
|||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onClick={handleSvgClick}
|
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) => {
|
{countryFeatures.map((featureItem) => {
|
||||||
const rawId = featureItem.id;
|
const rawId = featureItem.id;
|
||||||
const numericId = String(Number(rawId));
|
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"}
|
{saving ? <Spinner size="tiny" /> : "Save Schedule"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function BlocklistSourcesSection({ onRunImport, runImportRunning }: Sourc
|
|||||||
<Text size={500} weight="semibold">
|
<Text size={500} weight="semibold">
|
||||||
Blocklist Sources
|
Blocklist Sources
|
||||||
</Text>
|
</Text>
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
<div className={styles.headerActions}>
|
||||||
<Button icon={<PlayRegular />} appearance="secondary" onClick={onRunImport} disabled={runImportRunning}>
|
<Button icon={<PlayRegular />} appearance="secondary" onClick={onRunImport} disabled={runImportRunning}>
|
||||||
{runImportRunning ? <Spinner size="tiny" /> : "Run Now"}
|
{runImportRunning ? <Spinner size="tiny" /> : "Run Now"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import type { BlocklistSource, PreviewResponse } from "../../types/blocklist";
|
import type { BlocklistSource, PreviewResponse } from "../../types/blocklist";
|
||||||
|
|
||||||
@@ -21,7 +23,25 @@ interface PreviewDialogProps {
|
|||||||
fetchPreview: (id: number) => Promise<PreviewResponse>;
|
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 {
|
export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDialogProps): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
const [data, setData] = useState<PreviewResponse | null>(null);
|
const [data, setData] = useState<PreviewResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -49,7 +69,7 @@ export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDi
|
|||||||
<DialogTitle>Preview — {source?.name ?? ""}</DialogTitle>
|
<DialogTitle>Preview — {source?.name ?? ""}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ textAlign: "center", padding: "16px" }}>
|
<div className={styles.centeredLoader}>
|
||||||
<Spinner label="Downloading…" />
|
<Spinner label="Downloading…" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -59,11 +79,11 @@ export function PreviewDialog({ open, source, onClose, fetchPreview }: PreviewDi
|
|||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
{data && (
|
{data && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
<div className={styles.previewStack}>
|
||||||
<Text size={300}>
|
<Text size={300}>
|
||||||
{data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}:
|
{data.valid_count} valid IPs / {data.skipped_count} skipped of {data.total_lines} total lines. Showing first {data.entries.length}:
|
||||||
</Text>
|
</Text>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
<div className={styles.previewEntries}>
|
||||||
{data.entries.map((entry) => (
|
{data.entries.map((entry) => (
|
||||||
<div key={entry}>{entry}</div>
|
<div key={entry}>{entry}</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
Switch,
|
Switch,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
|
|
||||||
interface SourceFormValues {
|
interface SourceFormValues {
|
||||||
@@ -30,6 +32,14 @@ interface SourceFormDialogProps {
|
|||||||
onSubmit: (values: SourceFormValues) => void;
|
onSubmit: (values: SourceFormValues) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
contentStack: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: tokens.spacingVerticalL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function SourceFormDialog({
|
export function SourceFormDialog({
|
||||||
open,
|
open,
|
||||||
mode,
|
mode,
|
||||||
@@ -39,6 +49,7 @@ export function SourceFormDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: SourceFormDialogProps): React.JSX.Element {
|
}: SourceFormDialogProps): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
const [values, setValues] = useState<SourceFormValues>(initial);
|
const [values, setValues] = useState<SourceFormValues>(initial);
|
||||||
|
|
||||||
const handleOpen = useCallback((): void => {
|
const handleOpen = useCallback((): void => {
|
||||||
@@ -56,7 +67,7 @@ export function SourceFormDialog({
|
|||||||
<DialogBody>
|
<DialogBody>
|
||||||
<DialogTitle>{mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"}</DialogTitle>
|
<DialogTitle>{mode === "add" ? "Add Blocklist Source" : "Edit Blocklist Source"}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
<div className={styles.contentStack}>
|
||||||
{error && (
|
{error && (
|
||||||
<MessageBar intent="error">
|
<MessageBar intent="error">
|
||||||
<MessageBarBody>{error}</MessageBarBody>
|
<MessageBarBody>{error}</MessageBarBody>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export const useBlocklistStyles = makeStyles({
|
|||||||
|
|
||||||
tableWrapper: { overflowX: "auto" },
|
tableWrapper: { overflowX: "auto" },
|
||||||
actionsCell: { display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" },
|
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: {
|
centred: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -22,13 +23,14 @@ export const useBlocklistStyles = makeStyles({
|
|||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
},
|
},
|
||||||
scheduleField: { minWidth: "140px" },
|
scheduleField: { minWidth: "140px" },
|
||||||
|
saveButton: { alignSelf: "flex-end" },
|
||||||
metaRow: {
|
metaRow: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: tokens.spacingHorizontalL,
|
gap: tokens.spacingHorizontalL,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
paddingTop: tokens.spacingVerticalS,
|
paddingTop: tokens.spacingVerticalS,
|
||||||
},
|
},
|
||||||
metaItem: { display: "flex", flexDirection: "column", gap: "2px" },
|
metaItem: { display: "flex", flexDirection: "column", gap: tokens.spacingHorizontalXXS },
|
||||||
runResult: {
|
runResult: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
|
makeStyles,
|
||||||
|
mergeClasses,
|
||||||
tokens,
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { Checkmark16Regular } from "@fluentui/react-icons";
|
import { Checkmark16Regular } from "@fluentui/react-icons";
|
||||||
@@ -33,6 +35,32 @@ export interface AutoSaveIndicatorProps {
|
|||||||
/** Fade-out delay after "saved" status in milliseconds. */
|
/** Fade-out delay after "saved" status in milliseconds. */
|
||||||
const SAVED_FADE_DELAY_MS = 2000;
|
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.
|
* Compact inline indicator for auto-save state.
|
||||||
*
|
*
|
||||||
@@ -44,6 +72,7 @@ export function AutoSaveIndicator({
|
|||||||
errorText,
|
errorText,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: AutoSaveIndicatorProps): React.JSX.Element {
|
}: AutoSaveIndicatorProps): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
const [fadingOut, setFadingOut] = useState(false);
|
const [fadingOut, setFadingOut] = useState(false);
|
||||||
|
|
||||||
// Trigger the fade-out transition 2 s after the saved state is reached.
|
// 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.
|
// Always render the aria-live region so screen readers track changes.
|
||||||
return (
|
return (
|
||||||
<span
|
<span aria-live="polite" role="status" className={styles.root}>
|
||||||
aria-live="polite"
|
|
||||||
role="status"
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: tokens.spacingHorizontalXS,
|
|
||||||
minWidth: 80,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status === "saving" && (
|
{status === "saving" && (
|
||||||
<>
|
<>
|
||||||
<Spinner size="extra-tiny" />
|
<Spinner size="extra-tiny" />
|
||||||
<Text size={200} style={{ color: tokens.colorNeutralForeground2 }}>
|
<Text size={200} className={styles.savingText}>
|
||||||
Saving…
|
Saving…
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -83,15 +103,10 @@ export function AutoSaveIndicator({
|
|||||||
|
|
||||||
{status === "saved" && (
|
{status === "saved" && (
|
||||||
<span
|
<span
|
||||||
style={{
|
className={mergeClasses(
|
||||||
opacity: fadingOut ? 0 : 1,
|
styles.savedMotion,
|
||||||
transform: fadingOut ? "scale(0.95)" : "scale(1)",
|
fadingOut ? styles.savedHidden : undefined,
|
||||||
transition: `opacity ${tokens.durationNormal} ${tokens.curveDecelerateMid}, transform ${tokens.durationNormal} ${tokens.curveDecelerateMid}`,
|
)}
|
||||||
animationName: fadingOut ? undefined : "fadeInScale",
|
|
||||||
animationDuration: tokens.durationFast,
|
|
||||||
animationTimingFunction: tokens.curveDecelerateMid,
|
|
||||||
animationFillMode: "both",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
appearance="tint"
|
appearance="tint"
|
||||||
@@ -106,10 +121,7 @@ export function AutoSaveIndicator({
|
|||||||
|
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text size={200} className={styles.errorText}>
|
||||||
size={200}
|
|
||||||
style={{ color: tokens.colorPaletteRedForeground3 }}
|
|
||||||
>
|
|
||||||
{errorText ?? "Save failed."}
|
{errorText ?? "Save failed."}
|
||||||
</Text>
|
</Text>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
Spinner,
|
Spinner,
|
||||||
Textarea,
|
Textarea,
|
||||||
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||||
@@ -37,6 +38,37 @@ export interface RawConfigSectionProps {
|
|||||||
/** Minimum visible rows for the monospace text area. */
|
/** Minimum visible rows for the monospace text area. */
|
||||||
const MIN_ROWS = 15;
|
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
|
// Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -52,6 +84,7 @@ export function RawConfigSection({
|
|||||||
saveContent,
|
saveContent,
|
||||||
label = "Raw Configuration",
|
label = "Raw Configuration",
|
||||||
}: RawConfigSectionProps): React.JSX.Element {
|
}: RawConfigSectionProps): React.JSX.Element {
|
||||||
|
const styles = useStyles();
|
||||||
/** Raw text content; null means not yet loaded. */
|
/** Raw text content; null means not yet loaded. */
|
||||||
const [content, setContent] = useState<string | null>(null);
|
const [content, setContent] = useState<string | null>(null);
|
||||||
const [localText, setLocalText] = useState("");
|
const [localText, setLocalText] = useState("");
|
||||||
@@ -122,27 +155,20 @@ export function RawConfigSection({
|
|||||||
<Accordion collapsible onToggle={handleExpand}>
|
<Accordion collapsible onToggle={handleExpand}>
|
||||||
<AccordionItem value="raw">
|
<AccordionItem value="raw">
|
||||||
<AccordionHeader>
|
<AccordionHeader>
|
||||||
<span style={{ fontWeight: tokens.fontWeightSemibold }}>{label}</span>
|
<span className={styles.headerText}>{label}</span>
|
||||||
</AccordionHeader>
|
</AccordionHeader>
|
||||||
<AccordionPanel>
|
<AccordionPanel>
|
||||||
{fetchLoading && (
|
{fetchLoading && (
|
||||||
<div
|
<div className={styles.loadingRow}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: tokens.spacingHorizontalS,
|
|
||||||
padding: tokens.spacingVerticalM,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spinner size="tiny" />
|
<Spinner size="tiny" />
|
||||||
<span style={{ color: tokens.colorNeutralForeground3 }}>
|
<span className={styles.loadingText}>
|
||||||
Loading…
|
Loading…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fetchError && (
|
{fetchError && (
|
||||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
<MessageBar intent="error" className={styles.errorBar}>
|
||||||
<MessageBarBody>{fetchError}</MessageBarBody>
|
<MessageBarBody>{fetchError}</MessageBarBody>
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
@@ -156,23 +182,10 @@ export function RawConfigSection({
|
|||||||
}}
|
}}
|
||||||
resize="vertical"
|
resize="vertical"
|
||||||
rows={MIN_ROWS}
|
rows={MIN_ROWS}
|
||||||
style={{
|
className={styles.textArea}
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
width: "100%",
|
|
||||||
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
|
|
||||||
marginBottom: tokens.spacingVerticalS,
|
|
||||||
}}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
/>
|
/>
|
||||||
<div
|
<div className={styles.controlsRow}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: tokens.spacingHorizontalS,
|
|
||||||
marginTop: tokens.spacingVerticalXS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* remains fast even when a jail contains thousands of banned IPs.
|
* remains fast even when a jail contains thousands of banned IPs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -102,10 +103,19 @@ const useStyles = makeStyles({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: tokens.spacingHorizontalXS,
|
gap: tokens.spacingHorizontalXS,
|
||||||
},
|
},
|
||||||
|
rowsPerPageDropdown: {
|
||||||
|
minWidth: "80px",
|
||||||
|
},
|
||||||
mono: {
|
mono: {
|
||||||
fontFamily: "Consolas, 'Courier New', monospace",
|
fontFamily: tokens.fontFamilyMonospace,
|
||||||
fontSize: tokens.fontSizeBase200,
|
fontSize: tokens.fontSizeBase200,
|
||||||
},
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
},
|
||||||
|
emptyValue: {
|
||||||
|
color: tokens.colorNeutralForeground4,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -118,64 +128,6 @@ interface BanRow {
|
|||||||
onUnban: (ip: string) => Promise<void>;
|
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
|
// Props
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -224,6 +176,58 @@ export function BannedIpsSection({
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const sectionStyles = useCommonSectionStyles();
|
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) => ({
|
const rows: BanRow[] = items.map((ban) => ({
|
||||||
ban,
|
ban,
|
||||||
onUnban,
|
onUnban,
|
||||||
@@ -286,7 +290,7 @@ export function BannedIpsSection({
|
|||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className={styles.centred}>
|
<div className={styles.centred}>
|
||||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
<Text size={200} className={styles.emptyText}>
|
||||||
No IPs currently banned in this jail.
|
No IPs currently banned in this jail.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +338,7 @@ export function BannedIpsSection({
|
|||||||
onPageChange(1);
|
onPageChange(1);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ minWidth: "80px" }}
|
className={styles.rowsPerPageDropdown}
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((n) => (
|
{PAGE_SIZE_OPTIONS.map((n) => (
|
||||||
<Option key={n} value={String(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 { Link } from "react-router-dom";
|
||||||
import { formatSeconds } from "../../utils/formatDate";
|
import { formatSeconds } from "../../utils/formatDate";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import {
|
import {
|
||||||
ArrowClockwiseRegular,
|
ArrowClockwiseRegular,
|
||||||
@@ -28,77 +30,27 @@ import { useJailsPageStyles } from "./jailsPageStyles";
|
|||||||
import { useJails } from "../../hooks/useJails";
|
import { useJails } from "../../hooks/useJails";
|
||||||
import type { JailSummary } from "../../types/jail";
|
import type { JailSummary } from "../../types/jail";
|
||||||
|
|
||||||
const jailColumns = [
|
const useOverviewStyles = makeStyles({
|
||||||
{
|
link: {
|
||||||
columnId: "name",
|
textDecoration: "none",
|
||||||
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),
|
|
||||||
},
|
},
|
||||||
{
|
jailName: {
|
||||||
columnId: "status",
|
fontFamily: tokens.fontFamilyMonospace,
|
||||||
renderHeaderCell: () => "Status",
|
fontSize: tokens.fontSizeBase200,
|
||||||
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;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
badgeCount: {
|
||||||
columnId: "backend",
|
marginLeft: tokens.spacingHorizontalXS,
|
||||||
renderHeaderCell: () => "Backend",
|
|
||||||
renderCell: (j: JailSummary) => <Text size={200}>{j.backend}</Text>,
|
|
||||||
compare: (a: JailSummary, b: JailSummary) => a.backend.localeCompare(b.backend),
|
|
||||||
},
|
},
|
||||||
{
|
statusActionGroup: {
|
||||||
columnId: "banned",
|
display: "flex",
|
||||||
renderHeaderCell: () => "Banned",
|
gap: tokens.spacingHorizontalS,
|
||||||
renderCell: (j: JailSummary) => (
|
alignItems: "center",
|
||||||
<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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function JailOverviewSection(): React.JSX.Element {
|
export function JailOverviewSection(): React.JSX.Element {
|
||||||
const styles = useJailsPageStyles();
|
const pageStyles = useJailsPageStyles();
|
||||||
|
const overviewStyles = useOverviewStyles();
|
||||||
const sectionStyles = useCommonSectionStyles();
|
const sectionStyles = useCommonSectionStyles();
|
||||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = useJails();
|
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } = useJails();
|
||||||
const [opError, setOpError] = useState<string | null>(null);
|
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 (
|
return (
|
||||||
<div className={sectionStyles.section}>
|
<div className={sectionStyles.section}>
|
||||||
<div className={sectionStyles.sectionHeader}>
|
<div className={sectionStyles.sectionHeader}>
|
||||||
<Text as="h2" size={500} weight="semibold">
|
<Text as="h2" size={500} weight="semibold">
|
||||||
Jail Overview
|
Jail Overview
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<Badge appearance="tint" style={{ marginLeft: "8px" }}>
|
<Badge appearance="tint" className={overviewStyles.badgeCount}>
|
||||||
{String(total)}
|
{String(total)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<div className={styles.actionRow}>
|
<div className={pageStyles.actionRow}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
@@ -148,11 +166,11 @@ export function JailOverviewSection(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && jails.length === 0 ? (
|
{loading && jails.length === 0 ? (
|
||||||
<div className={styles.centred}>
|
<div className={pageStyles.centred}>
|
||||||
<Spinner label="Loading jails…" />
|
<Spinner label="Loading jails…" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.tableWrapper}>
|
<div className={pageStyles.tableWrapper}>
|
||||||
<DataGrid items={jails} columns={jailColumns} getRowId={(j: JailSummary) => j.name} focusMode="composite">
|
<DataGrid items={jails} columns={jailColumns} getRowId={(j: JailSummary) => j.name} focusMode="composite">
|
||||||
<DataGridHeader>
|
<DataGridHeader>
|
||||||
<DataGridRow>
|
<DataGridRow>
|
||||||
@@ -166,7 +184,7 @@ export function JailOverviewSection(): React.JSX.Element {
|
|||||||
if (columnId === "status") {
|
if (columnId === "status") {
|
||||||
return (
|
return (
|
||||||
<DataGridCell>
|
<DataGridCell>
|
||||||
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
|
<div className={overviewStyles.statusActionGroup}>
|
||||||
{renderCell(item)}
|
{renderCell(item)}
|
||||||
<Tooltip content={item.running ? "Stop jail" : "Start jail"} relationship="label">
|
<Tooltip content={item.running ? "Stop jail" : "Start jail"} relationship="label">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user