Files
BanGUI/Docs/Tasks.md
Lukas 6bb38dbd8c Add ignore-self toggle to Jail Detail page
Implements the missing UI control for POST /api/jails/{name}/ignoreself:
- Add jailIgnoreSelf endpoint constant to endpoints.ts
- Add toggleIgnoreSelf(name, on) API function to jails.ts
- Expose toggleIgnoreSelf action from useJailDetail hook
- Replace read-only 'ignore self' badge with a Fluent Switch in
  IgnoreListSection to allow enabling/disabling the flag per jail
- Add 5 vitest tests for checked/unchecked state and toggle behaviour
2026-03-14 20:24:49 +01:00

31 KiB
Raw Blame History

BanGUI — Task List

This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.


Task 1 — Remove "Currently Banned IPs" section from the Jails page

Status: done

Summary: Removed ActiveBansSection component, buildBanColumns helper, fmtTimestamp helper, ActiveBan type import, Dialog/DeleteRegular/DismissRegular imports from JailsPage.tsx. Updated file-level and component-level JSDoc to say "three sections". useActiveBans kept for banIp/unbanIp used by BanUnbanForm.

Page: /jails — rendered by frontend/src/pages/JailsPage.tsx

The Jails page currently has four sections: Jail Overview, Ban / Unban IP, Currently Banned IPs, and IP Lookup. Remove the "Currently Banned IPs" section entirely.

What to do

  1. In frontend/src/pages/JailsPage.tsx, find the ActiveBansSection sub-component (the function that renders the "Currently Banned IPs" heading, the DataGrid of active bans, the refresh/clear-all buttons, and the confirmation dialog). Delete the entire ActiveBansSection function.
  2. In the JailsPage component at the bottom of the same file, remove the <ActiveBansSection /> JSX element from the returned markup.
  3. Remove any imports, hooks, types, or helper functions that were only used by ActiveBansSection and are now unused (e.g. useActiveBans if it is no longer referenced elsewhere, the buildBanColumns helper, the ActiveBan type import, etc.). Check the remaining code — useActiveBans is also destructured for banIp/unbanIp in JailsPage, so keep it if still needed there.
  4. Update the file-level JSDoc comment at the top of the file: change the list from four sections to three and remove the "Currently Banned IPs" bullet.
  5. Update the JSDoc on the JailsPage component to say "three sections" instead of "four sections".

Verification

  • npx tsc --noEmit passes with no errors.
  • npx eslint src/pages/JailsPage.tsx reports no warnings.
  • The /jails page renders without the "Currently Banned IPs" section; the remaining three sections (Jail Overview, Ban/Unban IP, IP Lookup) still work.

Task 2 — Remove "Jail Distribution" section from the Dashboard

Status: done

Summary: Removed JailDistributionChart import and JSX block from DashboardPage.tsx. The component file is retained (still importable) but no longer rendered.

Page: / (Dashboard) — rendered by frontend/src/pages/DashboardPage.tsx

The Dashboard currently shows: Server Status Bar, Filter Bar, Ban Trend, Top Countries, Jail Distribution, and Ban List. Remove the "Jail Distribution" section.

What to do

  1. In frontend/src/pages/DashboardPage.tsx, delete the entire {/* Jail Distribution section */} block — this is the <div className={styles.section}> that wraps the <JailDistributionChart> component (approximately lines 164177).
  2. Remove the JailDistributionChart import at the top of the file (import { JailDistributionChart } from "../components/JailDistributionChart";).
  3. If the JailDistributionChart component file (frontend/src/components/JailDistributionChart.tsx) is not imported anywhere else in the codebase, you may optionally delete it, but this is not required.

Verification

  • npx tsc --noEmit passes with no errors.
  • npx eslint src/pages/DashboardPage.tsx reports no warnings.
  • The Dashboard renders without the "Jail Distribution" chart; all other sections remain functional.

Task 3 — Fix transparent pie chart in "Top Countries" on the Dashboard

Status: done

Summary: Added Cell import from recharts and rendered per-slice <Cell key={index} fill={slice.fill} /> children inside <Pie> in TopCountriesPieChart.tsx. Added // eslint-disable-next-line @typescript-eslint/no-deprecated since recharts v3 type-marks Cell as deprecated but it remains the correct mechanism for per-slice colours.

Page: / (Dashboard) — pie chart rendered by frontend/src/components/TopCountriesPieChart.tsx

The pie chart in the "Top Countries" section is transparent (invisible slices). The root cause is that each slice's fill colour is set on the data objects but the <Pie> component does not apply them. Recharts needs either a fill prop on <Pie>, per-slice <Cell> elements, or the data items' fill field to be picked up correctly.

What to do

  1. Open frontend/src/components/TopCountriesPieChart.tsx.
  2. The buildSlices helper already attaches a resolved fill colour string to every SliceData item. However, the <Pie> element does not render individual <Cell> elements to apply those colours.
  3. Import Cell from recharts:
    import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
    
  4. Inside the <Pie> element, add child <Cell> elements that map each slice to its colour:
    <Pie
      data={slices}
      dataKey="value"
      nameKey="name"
      cx="50%"
      cy="50%"
      outerRadius={90}
      label={}
      labelLine={false}
    >
      {slices.map((slice, index) => (
        <Cell key={index} fill={slice.fill} />
      ))}
    </Pie>
    
    This ensures each pie slice is painted with the colour from CHART_PALETTE that buildSlices resolved.

Verification

  • npx tsc --noEmit passes with no errors.
  • The pie chart on the Dashboard now displays coloured slices (blue, red, green, gold, purple) matching the Fluent palette.
  • The legend and tooltip still work and show correct country names and percentages.

Task 4 — Fix log viewer rejecting fail2ban log path under /config/log

Status: done

Summary: Added "/config/log" to _SAFE_LOG_PREFIXES tuple in config_service.py and updated the error message to reference both allowed prefixes (/var/log and /config/log). All existing tests continue to pass.

Error: API error 400: {"detail":"Log path '/config/log/fail2ban/fail2ban.log' is outside the allowed directory. Only paths under /var/log are permitted."}

Root cause: The linuxserver/fail2ban Docker image writes its own log to /config/log/fail2ban/fail2ban.log (this is configured via logtarget in Docker/fail2ban-dev-config/fail2ban/fail2ban.conf). In the Docker Compose setup, the /config volume is shared between the fail2ban and backend containers, so the file exists and is readable. However, the backend's read_fail2ban_log function in backend/app/services/config_service.py hard-codes the allowed path prefix list as:

_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log",)

This causes any log target under /config/log/ to be rejected with a 400 error.

What to do

  1. Open backend/app/services/config_service.py.
  2. Find the _SAFE_LOG_PREFIXES constant (line ~771). Add "/config/log" as a second allowed prefix:
    _SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
    
    This is safe because:
    • /config/log is a volume mount controlled by the Docker Compose setup, not user input.
    • The path is still validated via Path.resolve() to prevent traversal (e.g. /config/log/../../etc/shadow resolves outside and is rejected).
  3. Update the error message on the next line (inside read_fail2ban_log) to reflect both allowed directories:
    "Only paths under /var/log or /config/log are permitted."
    
  4. Update the existing test test_path_outside_safe_dir_raises_operation_error in backend/tests/test_services/test_config_service.py — it currently patches _SAFE_LOG_PREFIXES to ("/var/log",) in the assertion, so it will still pass. Verify no other tests break.

Verification

  • python -m pytest backend/tests/test_services/test_config_service.py -q passes.
  • The log viewer page in the UI successfully loads the fail2ban log file at /config/log/fail2ban/fail2ban.log without a 400 error.

Task 5 — Harden jail activation: block on missing logpaths and improve post-activation health checks

Status: done

Summary:

  • Part A (config_file_service.py): activate_jail now refuses to proceed when _validate_jail_config_sync returns any issue with field in ("filter", "logpath"). Returns active=False, fail2ban_running=True with a descriptive message without writing to disk or reloading fail2ban.
  • Part B (ActivateJailDialog.tsx): All validation issues are now blocking (blockingIssues = validationIssues); the advisory-only logpath exclusion was removed.
  • Part C (config.py router): After activation, await _run_probe(request.app) is called immediately to refresh the cached fail2ban status.
  • Part D (health.py): /api/health now returns {"status": "ok", "fail2ban": "online"|"offline"} from cached probe state.
  • Tests: Added TestActivateJailBlocking (3 tests) in test_config_file_service.py; added test_200_with_active_false_on_missing_logpath in test_config.py; updated 5 existing TestActivateJail/TestActivateJailReloadArgs tests to mock _validate_jail_config_sync.

Problem description

Activating a jail whose logpath references a non-existent file (e.g. airsonic-auth.conf referencing a log file that doesn't exist) causes fail2ban to crash entirely. All previously running jails go down (Active Jails drops from 2 → 0). The GUI still shows "Service Health: Running" because:

  1. The backend /api/health endpoint (backend/app/routers/health.py) only checks that the FastAPI process itself is alive — it does not probe fail2ban. So Docker health checks pass even when fail2ban is dead.
  2. The background health probe runs every 30 seconds. If the user checks the dashboard during the window between the crash and the next probe, the cached status is stale (still shows "Running").
  3. The pre-activation validation (_validate_jail_config_sync in backend/app/services/config_file_service.py) treats missing log paths as warnings only (field="logpath" issues). The ActivateJailDialog frontend component filters these as "advisory" and does not block activation. This means a jail with a non-existent log file is activated, causing fail2ban to crash on reload.

What to do

Part A — Block activation when log files don't exist (backend)

File: backend/app/services/config_file_service.py

  1. In _validate_jail_config_sync() (around line 715723), change the log path existence check from a warning to an error for literal, non-glob paths. Currently it appends a JailValidationIssue with field="logpath" but the function still returns valid=True if these are the only issues. Instead, treat a missing logpath as a blocking validation failure — the valid field at the bottom (line ~729) already uses len(issues) == 0, so if the issue is appended it will set valid=False.

    The current code already does this correctly — the issue is in activate_jail() itself. Find the post-validation block (around line 11301138) where warnings are collected. Currently activation always proceeds regardless of validation result. Change this:

  2. In activate_jail(), after running _validate_jail_config_sync, check validation_result.valid. If it is False and any issue has field in ("filter", "logpath"), refuse to activate. Return a JailActivationResponse with active=False, fail2ban_running=True, and a descriptive message listing the blocking issues. This prevents writing enabled = true and reloading fail2ban with a known-bad config.

    # Block activation on critical validation failures (missing filter or logpath).
    blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")]
    if blocking:
        log.warning("jail_activation_blocked", jail=name, issues=[str(i) for i in blocking])
        return JailActivationResponse(
            name=name,
            active=False,
            fail2ban_running=True,
            validation_warnings=[f"{i.field}: {i.message}" for i in validation_result.issues],
            message=(
                f"Jail {name!r} cannot be activated: "
                + "; ".join(i.message for i in blocking)
            ),
        )
    

Part B — Block activation in the frontend dialog

File: frontend/src/components/config/ActivateJailDialog.tsx

Currently blockingIssues is computed by filtering out logpath issues (line ~175):

const blockingIssues = validationIssues.filter((i) => i.field !== "logpath");

Change this so that logpath issues are blocking too:

const blockingIssues = validationIssues.filter(
  (i) => i.field !== "logpath" || i.message.includes("not found"),
);

Or simply remove the logpath exclusion entirely so all validation issues block:

const blockingIssues = validationIssues; // all issues block activation
const advisoryIssues: JailValidationIssue[] = []; // nothing is advisory anymore

The "Activate" button should remained disabled when blockingIssues.length > 0 (this logic already exists).

Part C — Run an immediate health probe after activation (backend)

File: backend/app/routers/config.pyactivate_jail endpoint (around line 640660)

After config_file_service.activate_jail() returns, trigger an immediate health check so the cached status is updated right away (instead of waiting up to 30 seconds):

# Force an immediate health probe to refresh cached status.
from app.tasks.health_check import _run_probe
await _run_probe(request.app)

Add this right after the last_activation recording block (around line 653), before the return result. This ensures the dashboard immediately reflects the current fail2ban state.

Part D — Include fail2ban liveness in /api/health endpoint

File: backend/app/routers/health.py

The current health endpoint always returns {"status": "ok"}. Enhance it to also report fail2ban status from the cached probe:

@router.get("/health", summary="Application health check")
async def health_check(request: Request) -> JSONResponse:
    cached: ServerStatus = getattr(
        request.app.state, "server_status", ServerStatus(online=False)
    )
    return JSONResponse(content={
        "status": "ok",
        "fail2ban": "online" if cached.online else "offline",
    })

Keep the HTTP status code as 200 so Docker health checks don't restart the backend container when fail2ban is down. But having "fail2ban": "offline" in the response allows monitoring and debugging.

Tests to add or update

File: backend/tests/test_services/test_config_file_service.py

  1. Add test: test_activate_jail_blocked_when_logpath_missing — mock _validate_jail_config_sync to return valid=False with a logpath issue. Assert activate_jail() returns active=False and fail2ban_running=True without calling reload_all.

  2. Add test: test_activate_jail_blocked_when_filter_missing — same pattern but with a filter issue.

  3. Add test: test_activate_jail_proceeds_when_only_regex_warnings — mock validation with a non-blocking failregex issue and assert activation still proceeds.

File: backend/tests/test_routers/test_config.py

  1. Add test: test_activate_returns_400_style_response_on_missing_logpath — POST to /api/config/jails/{name}/activate with a jail that has a missing logpath. Assert the response body has active=False and contains the logpath error message.

File: backend/tests/test_tasks/test_health_check.py

  1. Existing tests should still pass — _run_probe behavior is unchanged.

Verification

  1. Run backend tests:

    .venv/bin/python -m pytest backend/tests/ -q --tb=short
    

    All tests pass with no failures.

  2. Run frontend type check and lint:

    cd frontend && npx tsc --noEmit && npx eslint src/components/config/ActivateJailDialog.tsx
    
  3. Manual test with running server:

    • Go to /config, find a jail with a non-existent logpath (e.g. airsonic-auth).
    • Click "Activate" — the dialog should show a blocking error about the missing log file and the Activate button should be disabled.
    • Verify that fail2ban is still running with the original jails intact (Active Jails count unchanged).
    • Go to dashboard — "Service Health" should correctly reflect the live fail2ban state.

Task 6 — Run immediate health probe after jail deactivation

Status: done

Summary: deactivate_jail endpoint in config.py now captures the service result, calls await _run_probe(request.app), and then returns the result — matching the behaviour added to activate_jail in Task 5. Added test_deactivate_triggers_health_probe to TestDeactivateJail in test_config.py (verifies _run_probe is awaited once on success). Also fixed 3 pre-existing ruff UP017 warnings (datetime.timezone.utcdatetime.UTC) in test_config.py.

The deactivate_jail endpoint in backend/app/routers/config.py is inconsistent with activate_jail: after activation the router calls await _run_probe(request.app) to immediately refresh the cached fail2ban status (added in Task 5 Part C). Deactivation performs a full reload_all which also causes a brief fail2ban restart; without the probe the dashboard can show a stale active-jail count for up to 30 seconds.

What to do

File: backend/app/routers/config.pydeactivate_jail endpoint (around line 670698)

  1. The handler currently calls config_file_service.deactivate_jail(...) and returns its result directly via return await .... Refactor it to capture the result first, run the probe, then return:

    result = await config_file_service.deactivate_jail(config_dir, socket_path, name)
    # Force an immediate health probe so the cached status reflects the current
    # fail2ban state (reload changes the active-jail count).
    await _run_probe(request.app)
    return result
    

    _run_probe is already imported at the top of the file (added in Task 5).

Tests to add or update

File: backend/tests/test_routers/test_config.py

  1. Add test: test_deactivate_triggers_health_probe — in the TestDeactivateJail class, mock both config_file_service.deactivate_jail and app.routers.config._run_probe. POST to /api/config/jails/sshd/deactivate and assert that _run_probe was awaited exactly once.

  2. Update test test_200_deactivates_jail — it already passes without the probe mock, so no changes are needed unless the test client setup causes _run_probe to raise. Add a mock for _run_probe to prevent real socket calls in that test too.

Verification

  1. Run backend tests:

    .venv/bin/python -m pytest backend/tests/test_routers/test_config.py -q --tb=short
    

    All tests pass with no failures.

  2. Run the full backend suite to confirm no regressions:

    .venv/bin/python -m pytest backend/tests/ --no-cov --tb=no -q
    
  3. Manual test with running server:

    • Go to /config, find an active jail and click "Deactivate".
    • Immediately navigate to the Dashboard — "Active Jails" count should already reflect the reduced count without any delay.

Task 7 — Fix ActivateJailDialog not honouring backend rejection and mypy false positive

Status: done

Summary:

  • Bug 1 (ActivateJailDialog.tsx): Added || blockingIssues.length > 0 to the "Activate" button's disabled prop so the button is correctly greyed-out when pre-validation surfaces any blocking issue (filter or logpath problems).
  • Bug 2 (ActivateJailDialog.tsx): handleConfirm's .then() handler now checks result.active first. When active=false the dialog stays open and shows result.message as an error; resetForm() and onActivated() are only called on active=true.
  • Bug 3 (config.py): Added # type: ignore[call-arg] with a comment to Settings() call to suppress the mypy strict-mode false positive caused by pydantic-settings loading required fields from environment variables at runtime.
  • Tests: Added ActivateJailDialog.test.tsx with 5 tests (button disabled on blocking issues, button enabled on clean validation, dialog stays open on backend rejection, onActivated called on success, onCrashDetected fired when fail2ban_running=false).

Problem description

Two independent bugs were introduced during Tasks 56:

Bug 1 — "Activate" button is never disabled on validation errors (frontend)

In frontend/src/components/config/ActivateJailDialog.tsx, Task 5 Part B set:

const blockingIssues = validationIssues; // all issues block activation

but the "Activate" <Button> disabled prop was never updated to include blockingIssues.length > 0:

disabled={submitting || validating}   // BUG: missing `|| blockingIssues.length > 0`

The pre-validation error message renders correctly, but the button stays clickable. A user can press "Activate" despite seeing a red error — the backend will refuse (returning active=false) but the UX is broken and confusing.

Bug 2 — Dialog closes and fires onActivated() even when backend rejects activation (frontend)

handleConfirm's .then() handler never inspects result.active. When the backend blocks activation and returns { active: false, message: "...", validation_warnings: [...] }, the frontend still:

  1. Calls setValidationWarnings(result.validation_warnings) — sets warnings in state.
  2. Immediately calls resetForm() — which clears the newly-set warnings.
  3. Calls onActivated() — which triggers the parent to refresh the jail list (and may close the dialog).

The user sees the dialog briefly appear to succeed, the parent refreshes, but the jail never activated.

Bug 3 — mypy strict false positive in config.py

get_settings() calls Settings() without arguments. mypy strict mode flags this as:

backend/app/config.py:88: error: Missing named argument "session_secret" for "Settings"  [call-arg]

This is a known pydantic-settings limitation: the library loads required fields from environment variables at runtime, which mypy cannot see statically. A targeted suppression with an explanatory comment is the correct fix.

What to do

Part A — Disable "Activate" button when blocking issues are present (frontend)

File: frontend/src/components/config/ActivateJailDialog.tsx

Find the "Activate" <Button> near the bottom of the returned JSX and change its disabled prop:

// Before:
disabled={submitting || validating}

// After:
disabled={submitting || validating || blockingIssues.length > 0}

Part B — Handle active=false response from backend (frontend)

File: frontend/src/components/config/ActivateJailDialog.tsx

In handleConfirm's .then() callback, add a check for result.active before calling resetForm() and onActivated():

.then((result) => {
  if (!result.active) {
    // Backend rejected the activation (e.g. missing logpath).
    // Show the server's message and keep the dialog open.
    setError(result.message);
    return;
  }
  if (result.validation_warnings.length > 0) {
    setValidationWarnings(result.validation_warnings);
  }
  resetForm();
  if (!result.fail2ban_running) {
    onCrashDetected?.();
  }
  onActivated();
})

Part C — Fix mypy false positive (backend)

File: backend/app/config.py

Add a targeted # type: ignore[call-arg] with an explanatory comment to the Settings() call in get_settings():

return Settings()  # type: ignore[call-arg]  # pydantic-settings populates required fields from env vars

Tests to add or update

File: frontend/src/components/config/__tests__/ActivateJailDialog.test.tsx (new file)

Write tests covering:

  1. test_activate_button_disabled_when_blocking_issues — render the dialog with mocked validateJailConfig returning an issue with field="logpath". Assert the "Activate" button is disabled.

  2. test_activate_button_enabled_when_no_issues — render the dialog with mocked validateJailConfig returning no issues. Assert the "Activate" button is enabled after validation completes.

  3. test_dialog_stays_open_when_backend_returns_active_false — mock activateJail to return { active: false, message: "Jail cannot be activated", validation_warnings: [], fail2ban_running: true, name: "test" }. Click "Activate". Assert: (a) onActivated is NOT called; (b) the error message text appears.

  4. test_dialog_calls_on_activated_when_backend_returns_active_true — mock activateJail to return { active: true, message: "ok", validation_warnings: [], fail2ban_running: true, name: "test" }. Click "Activate". Assert onActivated is called once.

  5. test_crash_detected_callback_fires_when_fail2ban_not_running — mock activateJail to return active: true, fail2ban_running: false. Assert onCrashDetected is called.

Verification

  1. Run frontend type check and lint:

    cd frontend && npx tsc --noEmit && npx eslint src/components/config/ActivateJailDialog.tsx
    

    Zero errors and zero warnings.

  2. Run frontend tests:

    cd frontend && npx vitest run src/components/config/__tests__/ActivateJailDialog
    

    All 5 new tests pass.

  3. Run mypy:

    .venv/bin/mypy backend/app/ --strict
    

    Zero errors.


Task 8 — Add "ignore self" toggle to Jail Detail page

Status: done

Summary: Added jailIgnoreSelf endpoint constant to endpoints.ts; added toggleIgnoreSelf(name, on) API function to jails.ts; extended useJailDetail return type and hook implementation to expose toggleIgnoreSelf; replaced the read-only "ignore self" badge in IgnoreListSection (JailDetailPage.tsx) with a Fluent UI Switch that calls the toggle action and surfaces any error in the existing opError message bar; added 5 new tests in JailDetailIgnoreSelf.test.tsx covering checked/unchecked rendering, toggle-on, toggle-off, and error display.

Page: /jails/:name — rendered by frontend/src/pages/JailDetailPage.tsx

Problem description

Features.md §5 (Jail Management / IP Whitelist) requires: "Toggle the 'ignore self' option per jail, which automatically excludes the server's own IP addresses."

The backend already exposes POST /api/jails/{name}/ignoreself (accepts a JSON boolean on). The useJailDetail hook reads ignore_self from the GET /api/jails/{name} response and exposes it as ignoreSelf: boolean. However:

  1. No API wrapperfrontend/src/api/jails.ts has no toggleIgnoreSelf function, and frontend/src/api/endpoints.ts has no jailIgnoreSelf entry.
  2. No hook actionUseJailDetailResult in useJails.ts does not expose a toggleIgnoreSelf helper.
  3. Read-only UIIgnoreListSection in JailDetailPage.tsx shows an "ignore self" badge when enabled but has no control to change the setting.

As a result, users must use the fail2ban CLI to manage this flag even though the backend is ready.

What to do

Part A — Add endpoint constant (frontend)

File: frontend/src/api/endpoints.ts

Inside the Jails block, after jailIgnoreIp, add:

jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`,

Part B — Add API wrapper function (frontend)

File: frontend/src/api/jails.ts

After the delIgnoreIp function in the "Ignore list" section, add:

/**
 * Enable or disable the `ignoreself` flag for a jail.
 *
 * When enabled, fail2ban automatically adds the server's own IP addresses to
 * the ignore list so the host can never ban itself.
 *
 * @param name - Jail name.
 * @param on   - `true` to enable, `false` to disable.
 * @returns A {@link JailCommandResponse} confirming the change.
 * @throws {ApiError} On non-2xx responses.
 */
export async function toggleIgnoreSelf(
  name: string,
  on: boolean,
): Promise<JailCommandResponse> {
  return post<JailCommandResponse>(ENDPOINTS.jailIgnoreSelf(name), on);
}

Part C — Expose toggle action from hook (frontend)

File: frontend/src/hooks/useJails.ts

  1. Import toggleIgnoreSelf at the top (alongside the other API imports).
  2. Add toggleIgnoreSelf: (on: boolean) => Promise<void> to the UseJailDetailResult interface with a JSDoc comment: /** Enable or disable the ignoreself option for this jail. */.
  3. Inside useJailDetail, add the implementation:
const toggleIgnoreSelf = async (on: boolean): Promise<void> => {
  await toggleIgnoreSelfApi(name, on);
  load();
};

Alias the import as toggleIgnoreSelfApi to avoid shadowing the local function name.

  1. Add the function to the returned object.

Part D — Add toggle control to UI (frontend)

File: frontend/src/pages/JailDetailPage.tsx

  1. Accept toggleIgnoreSelf: (on: boolean) => Promise<void> in the IgnoreListSectionProps interface.
  2. Pass the function from useJailDetail down via the existing destructuring and JSX prop.
  3. Inside IgnoreListSection, render a <Switch> next to (or in place of) the read-only badge:
<Switch
  label="Ignore self (exclude this server's own IPs)"
  checked={ignoreSelf}
  onChange={(_e, data): void => {
    toggleIgnoreSelf(data.checked).catch((err: unknown) => {
      const msg = err instanceof Error ? err.message : String(err);
      setOpError(msg);
    });
  }}
/>

Import Switch from "@fluentui/react-components". Remove the existing read-only badge (it is replaced by the labelled switch, which is self-explanatory). Keep the existing opError state and <MessageBar> for error display.

Tests to add

File: frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx (new file)

Write tests that render the IgnoreListSection component (or the full JailDetailPage via a shallow-enough render) and cover:

  1. test_ignore_self_switch_is_checked_when_ignore_self_true — when ignoreSelf=true, the switch is checked.
  2. test_ignore_self_switch_is_unchecked_when_ignore_self_false — when ignoreSelf=false, the switch is unchecked.
  3. test_toggling_switch_calls_toggle_ignore_self — clicking the switch calls toggleIgnoreSelf with false (when it was true).
  4. test_toggle_error_shows_message_bar — when toggleIgnoreSelf rejects, the error message bar is rendered.

Verification

  1. Run frontend type check and lint:

    cd frontend && npx tsc --noEmit && npx eslint src/api/jails.ts src/api/endpoints.ts src/hooks/useJails.ts src/pages/JailDetailPage.tsx
    

    Zero errors and zero warnings.

  2. Run frontend tests:

    cd frontend && npx vitest run src/pages/__tests__/JailDetailIgnoreSelf
    

    All 4 new tests pass.

  3. Manual test with running server:

    • Go to /jails, click a running jail.
    • On the Jail Detail page, scroll to "Ignore List (IP Whitelist)".
    • Toggle the "Ignore self" switch on and off — the switch should reflect the live state and the change should survive a page refresh.