Task 1: Move Configuration to last position in sidebar NAV_ITEMS
Task 2: Add automatic rollback when jail activation fails
- Back up .local override file before writing
- Restore original file (or delete) on reload failure, health-check
failure, or jail not appearing post-reload
- Return recovered=True/False in JailActivationResponse
- Show warning/critical banner in ActivateJailDialog based on recovery
- Add _restore_local_file_sync and _rollback_activation_async helpers
- Add 3 new tests: rollback on reload failure, health-check failure,
and double failure (recovered=False)
Task 3: Color pie chart legend labels to match their slice color
- legendFormatter now returns ReactNode with span style={{ color }}
- Import LegendPayload from recharts/types/component/DefaultLegendContent
202 lines
6.0 KiB
TypeScript
202 lines
6.0 KiB
TypeScript
/**
|
|
* TopCountriesPieChart — shows the top 4 countries by ban count plus
|
|
* an "Other" slice aggregating all remaining countries.
|
|
*/
|
|
|
|
import {
|
|
Cell,
|
|
Legend,
|
|
Pie,
|
|
PieChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
} from "recharts";
|
|
import type { PieLabelRenderProps } from "recharts";
|
|
import type { LegendPayload } from "recharts/types/component/DefaultLegendContent";
|
|
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
|
import { tokens, makeStyles, Text } from "@fluentui/react-components";
|
|
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TOP_N = 4;
|
|
const OTHER_LABEL = "Other";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TopCountriesPieChartProps {
|
|
/** ISO alpha-2 country code → ban count. */
|
|
countries: Record<string, number>;
|
|
/** ISO alpha-2 country code → human-readable country name. */
|
|
countryNames: Record<string, string>;
|
|
}
|
|
|
|
interface SliceData {
|
|
name: string;
|
|
value: number;
|
|
/** Resolved fill colour for this slice. */
|
|
fill: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const useStyles = makeStyles({
|
|
wrapper: {
|
|
width: "100%",
|
|
minHeight: "280px",
|
|
},
|
|
emptyWrapper: {
|
|
width: "100%",
|
|
minHeight: "280px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
emptyText: {
|
|
color: tokens.colorNeutralForeground3,
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Build the 5-slice dataset from raw country maps, with resolved colours. */
|
|
function buildSlices(
|
|
countries: Record<string, number>,
|
|
countryNames: Record<string, string>,
|
|
palette: readonly string[],
|
|
): SliceData[] {
|
|
const entries = Object.entries(countries).sort(([, a], [, b]) => b - a);
|
|
const top = entries.slice(0, TOP_N);
|
|
const rest = entries.slice(TOP_N);
|
|
|
|
const slices: SliceData[] = top.map(([code, count], index) => ({
|
|
name: countryNames[code] ?? code,
|
|
value: count,
|
|
fill: palette[index % palette.length] ?? "",
|
|
}));
|
|
|
|
if (rest.length > 0) {
|
|
const otherTotal = rest.reduce((sum, [, c]) => sum + c, 0);
|
|
slices.push({
|
|
name: OTHER_LABEL,
|
|
value: otherTotal,
|
|
fill: palette[slices.length % palette.length] ?? "",
|
|
});
|
|
}
|
|
|
|
return slices;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom tooltip
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function PieTooltip(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;
|
|
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",
|
|
}}
|
|
>
|
|
<strong>{displayName}</strong>
|
|
<br />
|
|
{banCount != null
|
|
? `${String(banCount)} ban${banCount === 1 ? "" : "s"}`
|
|
: ""}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Pie chart showing the top 4 countries by ban count plus an "Other" slice.
|
|
*
|
|
* @param props - `countries` map and `countryNames` map from the
|
|
* `/api/dashboard/bans/by-country` response.
|
|
*/
|
|
export function TopCountriesPieChart({
|
|
countries,
|
|
countryNames,
|
|
}: TopCountriesPieChartProps): React.JSX.Element {
|
|
const styles = useStyles();
|
|
|
|
const resolvedPalette = CHART_PALETTE.map(resolveFluentToken);
|
|
const slices = buildSlices(countries, countryNames, resolvedPalette);
|
|
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
|
|
|
if (slices.length === 0) {
|
|
return (
|
|
<div className={styles.emptyWrapper}>
|
|
<Text className={styles.emptyText}>No bans in this time range.</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Format legend entries as "Country Name (xx%)" and colour them to match their slice. */
|
|
const legendFormatter = (
|
|
value: string,
|
|
entry: LegendPayload,
|
|
): React.ReactNode => {
|
|
const slice = slices.find((s) => s.name === value);
|
|
if (slice == null || total === 0) return value;
|
|
const pct = ((slice.value / total) * 100).toFixed(1);
|
|
return (
|
|
<span style={{ color: entry.color }}>
|
|
{value} ({pct}%)
|
|
</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.wrapper}>
|
|
<ResponsiveContainer width="100%" height={280}>
|
|
<PieChart>
|
|
<Pie
|
|
data={slices}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={90}
|
|
label={(labelProps: PieLabelRenderProps): string => {
|
|
const name = labelProps.name ?? "";
|
|
const percent = labelProps.percent ?? 0;
|
|
return `${name}: ${(percent * 100).toFixed(0)}%`;
|
|
}}
|
|
labelLine={false}
|
|
>
|
|
{slices.map((slice, index) => (
|
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
<Cell key={index} fill={slice.fill} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={PieTooltip} />
|
|
<Legend formatter={legendFormatter} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|