Implement tasks 1-3: sidebar order, jail activation rollback, pie chart colors

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
This commit was merged in pull request #1.
This commit is contained in:
2026-03-14 21:16:58 +01:00
parent 6bb38dbd8c
commit 4be2469f92
8 changed files with 449 additions and 581 deletions

View File

@@ -12,6 +12,7 @@ import {
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";
@@ -153,12 +154,19 @@ export function TopCountriesPieChart({
);
}
/** Format legend entries as "Country Name (xx%)" */
const legendFormatter = (value: string): string => {
/** 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 `${value} (${pct}%)`;
return (
<span style={{ color: entry.color }}>
{value} ({pct}%)
</span>
);
};
return (

View File

@@ -26,6 +26,7 @@ import {
Input,
MessageBar,
MessageBarBody,
MessageBarTitle,
Spinner,
Text,
tokens,
@@ -85,6 +86,7 @@ export function ActivateJailDialog({
const [logpath, setLogpath] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recoveryStatus, setRecoveryStatus] = useState<"recovered" | "unrecovered" | null>(null);
// Pre-activation validation state
const [validating, setValidating] = useState(false);
@@ -98,6 +100,7 @@ export function ActivateJailDialog({
setPort("");
setLogpath("");
setError(null);
setRecoveryStatus(null);
setValidationIssues([]);
setValidationWarnings([]);
};
@@ -153,10 +156,17 @@ export function ActivateJailDialog({
activateJail(jail.name, overrides)
.then((result) => {
if (!result.active) {
// Backend rejected the activation (e.g. missing logpath or filter).
// Show the server's message and keep the dialog open so the user
// can read the explanation without the dialog disappearing.
setError(result.message);
if (result.recovered === true) {
// Activation failed but the system rolled back automatically.
setRecoveryStatus("recovered");
} else if (result.recovered === false) {
// Activation failed and rollback also failed.
setRecoveryStatus("unrecovered");
} else {
// Backend rejected before writing (e.g. missing logpath or filter).
// Show the server's message and keep the dialog open.
setError(result.message);
}
return;
}
if (result.validation_warnings.length > 0) {
@@ -323,6 +333,31 @@ export function ActivateJailDialog({
onChange={(_e, d) => { setLogpath(d.value); }}
/>
</Field>
{recoveryStatus === "recovered" && (
<MessageBar
intent="warning"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed System Recovered</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed. The server
has been automatically recovered.
</MessageBarBody>
</MessageBar>
)}
{recoveryStatus === "unrecovered" && (
<MessageBar
intent="error"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed Manual Intervention Required</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed and
automatic recovery was unsuccessful. Manual intervention is
required.
</MessageBarBody>
</MessageBar>
)}
{error && (
<MessageBar
intent="error"

View File

@@ -185,9 +185,9 @@ const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", to: "/", icon: <GridRegular />, end: true },
{ label: "World Map", to: "/map", icon: <MapRegular /> },
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
{ label: "History", to: "/history", icon: <HistoryRegular /> },
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
];
// ---------------------------------------------------------------------------

View File

@@ -553,6 +553,13 @@ export interface JailActivationResponse {
fail2ban_running: boolean;
/** Non-fatal pre-activation validation warnings (e.g. missing log path). */
validation_warnings: string[];
/**
* Set when activation failed after the config file was already written.
* `true` = the system rolled back and recovered automatically.
* `false` = rollback also failed — manual intervention required.
* `undefined` = activation succeeded or failed before the file was written.
*/
recovered?: boolean;
}
// ---------------------------------------------------------------------------