diff --git a/Docs/Tasks.md b/Docs/Tasks.md
index 973e913..70c44ca 100644
--- a/Docs/Tasks.md
+++ b/Docs/Tasks.md
@@ -1,23 +1,3 @@
-## 12) Prop drilling in jail overview page
-- Where found:
- - [frontend/src/pages/jails/JailOverviewSection.tsx](frontend/src/pages/jails/JailOverviewSection.tsx)
- - [frontend/src/pages/JailsPage.tsx](frontend/src/pages/JailsPage.tsx)
-- Why this is needed:
- - Large prop chains increase coupling and refactor cost.
-- Goal:
- - Move jail state/actions into dedicated context or controller hook.
-- What to do:
- - Introduce JailContext (or equivalent local state container).
- - Remove multi-hop prop forwarding.
-- Possible traps and issues:
- - Context overuse can trigger broad rerenders if not split.
-- Docs changes needed:
- - Add frontend state ownership notes.
-- Doc references:
- - [Docs/Web-Development.md](Docs/Web-Development.md)
-
----
-
## 13) Config page is over-centralized
- Where found:
- [frontend/src/pages/ConfigPage.tsx](frontend/src/pages/ConfigPage.tsx)
diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md
index 001716c..e371a97 100644
--- a/Docs/Web-Development.md
+++ b/Docs/Web-Development.md
@@ -242,6 +242,78 @@ The distinction between **`pages/`** and **`components/`** is fundamental to the
**Example:** `BannedIpsSection` lives in `components/jail/` (not `pages/jail/`) because it is a reusable UI section that presents banned IPs. If a future report or dashboard also needed to show banned IPs, the same component could be imported and reused. By contrast, `JailDetailPage.tsx` lives in `pages/` because it is the top-level route component.
+### Tab Orchestration — ConfigPage Example
+
+When a page contains tab-based navigation (like the configuration page), isolate routing and tab state management into a dedicated **container component** to prevent the page from becoming over-centralized. This pattern applies to any multi-tab page.
+
+**Architecture:**
+
+1. **Page component** (`ConfigPage.tsx`) — renders page layout (header, title, description) and delegates tab routing to a container.
+2. **Container component** (`ConfigPageContainer.tsx`) — orchestrates tab navigation, manages which tab content is visible, and routes tab selection events.
+3. **Tab router hook** (`useTabRouter.ts`) — encapsulates tab state synchronization with browser history and supports deep linking (e.g., navigating directly to a specific tab with optional active item like a jail name).
+4. **Tab components** (`JailsTab.tsx`, `FiltersTab.tsx`, etc.) — domain-specific tab content; each is fully self-contained and receives tab-specific props only.
+
+**Component tree:**
+```
+ConfigPage (page layout)
+└── ConfigPageContainer (tab orchestration)
+ ├── useTabRouter (routing logic)
+ ├── JailsTab (jail editing UI)
+ ├── FiltersTab (filter editing UI)
+ ├── ActionsTab (action editing UI)
+ ├── ServerTab (server settings UI)
+ └── RegexTesterTab (regex testing UI)
+```
+
+**Benefits:**
+- **Focused pages** — `ConfigPage` renders only layout; routing logic is in the container.
+- **Reusable routing** — `useTabRouter` can be used by other pages with tab navigation.
+- **Isolated tabs** — each tab is a focused component; no shared state entanglement.
+- **Deep linking** — tab state is synchronized to browser history, allowing bookmarkable URLs and the back/forward buttons to work correctly.
+
+**Key pattern:**
+
+```tsx
+// hooks/useTabRouter.ts — routes and state
+export type ConfigTabId = "jails" | "filters" | "actions" | "server" | "regex";
+
+export function useTabRouter(): { activeTab: ConfigTabId; selectTab: (tab: ConfigTabId) => void; ... } {
+ const location = useLocation();
+ const navigate = useNavigate();
+ // Sync tab state to location.state for deep linking
+ // ... (see implementation)
+}
+
+// components/config/ConfigPageContainer.tsx — renders tabs
+export function ConfigPageContainer(): JSX.Element {
+ const { activeTab, selectTab } = useTabRouter();
+ return (
+ <>
+
+ {/* Tabs */}
+
+ {/* Tab content panels, conditionally rendered via CSS */}
+ >
+ );
+}
+
+// pages/ConfigPage.tsx — layout only
+export function ConfigPage(): JSX.Element {
+ return (
+
+ {/* Page header, title, description */}
+
+
+ );
+}
+```
+
+**Avoid:**
+
+- Mixing page layout, tab orchestration, and tab content in one file.
+- Duplicating tab state across multiple hooks or components.
+- Hardcoding tab IDs as strings — use the `ConfigTabId` type.
+
### Separation of Concerns
- **Pages** handle routing and compose layout + components — they contain no business logic.
diff --git a/frontend/src/components/config/ConfigPageContainer.tsx b/frontend/src/components/config/ConfigPageContainer.tsx
new file mode 100644
index 0000000..90fe164
--- /dev/null
+++ b/frontend/src/components/config/ConfigPageContainer.tsx
@@ -0,0 +1,96 @@
+/**
+ * ConfigPageContainer — orchestrates tab navigation and routing.
+ *
+ * Manages the TabList, routes tab selection events, and coordinates which
+ * tab content is displayed. Delegates rendering of individual tabs to
+ * specialized tab components.
+ *
+ * This component separates concerns: routing logic (useTabRouter) is isolated
+ * from rendering, and each tab is a focused, domain-specific component.
+ */
+
+import { useMemo } from "react";
+import { Tab, TabList, makeStyles, tokens } from "@fluentui/react-components";
+import { useTabRouter, type ConfigTabId } from "../../hooks/useTabRouter";
+import { ActionsTab } from "./ActionsTab";
+import { FiltersTab } from "./FiltersTab";
+import { JailsTab } from "./JailsTab";
+import { RegexTesterTab } from "./RegexTesterTab";
+import { ServerTab } from "./ServerTab";
+
+const useStyles = makeStyles({
+ tabPanel: {
+ display: "none",
+ },
+ tabPanelVisible: {
+ display: "block",
+ marginTop: tokens.spacingVerticalL,
+ animationName: "fadeInUp",
+ animationDuration: tokens.durationNormal,
+ animationTimingFunction: tokens.curveDecelerateMid,
+ animationFillMode: "both",
+ },
+});
+
+/**
+ * Container component for the configuration page.
+ *
+ * Renders the tab navigation bar and switches between tab content based on
+ * the currently selected tab. Tab state is managed by useTabRouter and
+ * synchronized with browser history.
+ *
+ * @returns JSX element with tab bar and active tab content.
+ */
+export function ConfigPageContainer(): React.JSX.Element {
+ const styles = useStyles();
+ const { activeTab, activeItem, selectTab } = useTabRouter();
+
+ // Map tab IDs to their corresponding components.
+ const tabComponents = useMemo(
+ () => ({
+ jails: ,
+ filters: ,
+ actions: ,
+ server: ,
+ regex: ,
+ }),
+ [activeItem],
+ );
+
+ return (
+ <>
+ {
+ selectTab(d.value as ConfigTabId);
+ }}
+ >
+ Jails
+ Filters
+ Actions
+ Server
+ Regex Tester
+
+
+
+ {tabComponents.jails}
+
+
+
+ {tabComponents.filters}
+
+
+
+ {tabComponents.actions}
+
+
+
+ {tabComponents.server}
+
+
+
+ {tabComponents.regex}
+
+ >
+ );
+}
diff --git a/frontend/src/components/config/__tests__/ConfigPageContainer.test.tsx b/frontend/src/components/config/__tests__/ConfigPageContainer.test.tsx
new file mode 100644
index 0000000..ac27c7f
--- /dev/null
+++ b/frontend/src/components/config/__tests__/ConfigPageContainer.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { FluentProvider, webLightTheme } from "@fluentui/react-components";
+import { MemoryRouter } from "react-router-dom";
+import { ConfigPageContainer } from "../ConfigPageContainer";
+
+// Mock all tab components to avoid deep render trees and API calls.
+vi.mock("../JailsTab", () => ({
+ JailsTab: ({ initialJail }: { initialJail?: string }) => (
+
+ JailsTab
+
+ ),
+}));
+
+vi.mock("../FiltersTab", () => ({
+ FiltersTab: () => FiltersTab
,
+}));
+
+vi.mock("../ActionsTab", () => ({
+ ActionsTab: () => ActionsTab
,
+}));
+
+vi.mock("../ServerTab", () => ({
+ ServerTab: () => ServerTab
,
+}));
+
+vi.mock("../RegexTesterTab", () => ({
+ RegexTesterTab: () => RegexTesterTab
,
+}));
+
+function renderContainer() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("ConfigPageContainer", () => {
+ it("renders the tab list", () => {
+ renderContainer();
+ expect(screen.getByRole("tablist")).toBeInTheDocument();
+ });
+
+ it("renders all tab buttons", () => {
+ renderContainer();
+ expect(screen.getByRole("tab", { name: /jails/i })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /filters/i })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /actions/i })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /server/i })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /regex tester/i })).toBeInTheDocument();
+ });
+
+ it("shows the Jails tab by default", () => {
+ renderContainer();
+ expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
+ });
+
+ it("switches to Filters tab when clicked", () => {
+ renderContainer();
+ fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
+ expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
+ });
+
+ it("switches to Actions tab when clicked", () => {
+ renderContainer();
+ fireEvent.click(screen.getByRole("tab", { name: /actions/i }));
+ expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
+ });
+
+ it("switches to Server tab when clicked", () => {
+ renderContainer();
+ fireEvent.click(screen.getByRole("tab", { name: /server/i }));
+ expect(screen.getByTestId("server-tab")).toBeInTheDocument();
+ });
+
+ it("switches to Regex Tester tab when clicked", () => {
+ renderContainer();
+ fireEvent.click(screen.getByRole("tab", { name: /regex tester/i }));
+ expect(screen.getByTestId("regex-tab")).toBeInTheDocument();
+ });
+
+ it("keeps all tab panels mounted (CSS display toggling)", () => {
+ renderContainer();
+ fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
+ expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
+ expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/config/index.ts b/frontend/src/components/config/index.ts
index 0daaaf5..d6b7cff 100644
--- a/frontend/src/components/config/index.ts
+++ b/frontend/src/components/config/index.ts
@@ -5,6 +5,7 @@
* import { FiltersTab, RegexList } from "../components/config";
*/
+export { ConfigPageContainer } from "./ConfigPageContainer";
export { ActionsTab } from "./ActionsTab";
export { ActionForm } from "./ActionForm";
export type { ActionFormProps } from "./ActionForm";
diff --git a/frontend/src/hooks/__tests__/useTabRouter.test.tsx b/frontend/src/hooks/__tests__/useTabRouter.test.tsx
new file mode 100644
index 0000000..dab12e6
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useTabRouter.test.tsx
@@ -0,0 +1,91 @@
+import { describe, it, expect } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { useTabRouter } from "../useTabRouter";
+
+describe("useTabRouter", () => {
+ it("initializes with jails tab selected by default", () => {
+ const { result } = renderHook(() => useTabRouter(), {
+ wrapper: ({ children }) => {children},
+ });
+
+ expect(result.current.activeTab).toBe("jails");
+ expect(result.current.activeItem).toBeNull();
+ });
+
+ it("selects a new tab", () => {
+ const { result } = renderHook(() => useTabRouter(), {
+ wrapper: ({ children }) => {children},
+ });
+
+ act(() => {
+ result.current.selectTab("filters");
+ });
+
+ expect(result.current.activeTab).toBe("filters");
+ });
+
+ it("clears active item when selecting a new tab", () => {
+ const { result } = renderHook(() => useTabRouter(), {
+ wrapper: ({ children }) => {children},
+ });
+
+ act(() => {
+ result.current.selectTabWithItem("jails", "test-jail");
+ });
+
+ expect(result.current.activeItem).toBe("test-jail");
+
+ act(() => {
+ result.current.selectTab("filters");
+ });
+
+ expect(result.current.activeItem).toBeNull();
+ });
+
+ it("selects a tab with an active item", () => {
+ const { result } = renderHook(() => useTabRouter(), {
+ wrapper: ({ children }) => {children},
+ });
+
+ act(() => {
+ result.current.selectTabWithItem("jails", "sshd");
+ });
+
+ expect(result.current.activeTab).toBe("jails");
+ expect(result.current.activeItem).toBe("sshd");
+ });
+
+ it("supports all valid tab IDs", () => {
+ const { result } = renderHook(() => useTabRouter(), {
+ wrapper: ({ children }) => {children},
+ });
+
+ const tabs = ["jails", "filters", "actions", "server", "regex"] as const;
+
+ tabs.forEach((tab) => {
+ act(() => {
+ result.current.selectTab(tab);
+ });
+ expect(result.current.activeTab).toBe(tab);
+ });
+ });
+
+ it("clears active item when setting item to null", () => {
+ const { result } = renderHook(() => useTabRouter(), {
+ wrapper: ({ children }) => {children},
+ });
+
+ act(() => {
+ result.current.selectTabWithItem("jails", "sshd");
+ });
+
+ expect(result.current.activeItem).toBe("sshd");
+
+ act(() => {
+ result.current.selectTabWithItem("jails", null);
+ });
+
+ expect(result.current.activeItem).toBeNull();
+ });
+});
diff --git a/frontend/src/hooks/useTabRouter.ts b/frontend/src/hooks/useTabRouter.ts
new file mode 100644
index 0000000..e0c4eee
--- /dev/null
+++ b/frontend/src/hooks/useTabRouter.ts
@@ -0,0 +1,85 @@
+/**
+ * useTabRouter — centralized tab routing and state management.
+ *
+ * Encapsulates tab navigation logic, URL state synchronization, and history
+ * management for the config page. Supports deep linking to specific tabs and
+ * optional item IDs (e.g., jail names).
+ */
+
+import { useEffect, useState, useCallback } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+
+/** Available tab IDs in the config page. */
+export type ConfigTabId = "jails" | "filters" | "actions" | "server" | "regex";
+
+/** Tab navigation state with optional active item/jail name. */
+export interface TabRouterState {
+ activeTab: ConfigTabId;
+ activeItem: string | null;
+}
+
+interface UseTabRouterReturn extends TabRouterState {
+ selectTab: (tab: ConfigTabId) => void;
+ selectTabWithItem: (tab: ConfigTabId, itemId: string | null) => void;
+}
+
+/**
+ * Hook for managing config page tab routing.
+ *
+ * Syncs tab selection to URL state via `location.state` so it persists across
+ * navigation. The activeItem (jail name, filter name, etc.) is also tracked
+ * to support deep linking.
+ *
+ * @returns Tab state and navigation functions.
+ */
+export function useTabRouter(): UseTabRouterReturn {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const [activeTab, setActiveTab] = useState("jails");
+ const [activeItem, setActiveItem] = useState(null);
+
+ // Sync from location.state on mount or when location changes.
+ useEffect(() => {
+ const state = location.state as { tab?: string; item?: string } | null;
+
+ if (state?.tab === "jails" || state?.tab === "filters" || state?.tab === "actions" || state?.tab === "server" || state?.tab === "regex") {
+ setActiveTab(state.tab);
+ }
+
+ if (state?.item) {
+ setActiveItem(state.item);
+ }
+ }, [location.state]);
+
+ const selectTab = useCallback(
+ (tab: ConfigTabId): void => {
+ setActiveTab(tab);
+ setActiveItem(null);
+ navigate(location.pathname, {
+ state: { tab },
+ replace: false,
+ });
+ },
+ [navigate, location.pathname],
+ );
+
+ const selectTabWithItem = useCallback(
+ (tab: ConfigTabId, itemId: string | null): void => {
+ setActiveTab(tab);
+ setActiveItem(itemId);
+ navigate(location.pathname, {
+ state: { tab, item: itemId },
+ replace: false,
+ });
+ },
+ [navigate, location.pathname],
+ );
+
+ return {
+ activeTab,
+ activeItem,
+ selectTab,
+ selectTabWithItem,
+ };
+}
diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx
index a448f2a..d8ef95f 100644
--- a/frontend/src/pages/ConfigPage.tsx
+++ b/frontend/src/pages/ConfigPage.tsx
@@ -1,8 +1,8 @@
/**
* Configuration page — fail2ban jail and server configuration editor.
*
- * Renders the top-level tab bar and delegates rendering to the appropriate
- * tab component from {@link ../components/config}.
+ * Top-level page component for the configuration interface. Renders page layout
+ * and delegates tab routing to {@link ConfigPageContainer}.
*
* Tabs:
* Jails — per-jail config accordion with inline editing
@@ -10,23 +10,10 @@
* Actions — structured action.d form editor
* Server — server-level settings, map thresholds, service health + log viewer
* Regex Tester — live pattern tester
- * Export — raw file editors for jail, filter, and action files
*/
-import { useEffect, useState } from "react";
-import { useLocation } from "react-router-dom";
-import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
-import {
- ActionsTab,
- FiltersTab,
- JailsTab,
- RegexTesterTab,
- ServerTab,
-} from "../components/config";
-
-// ---------------------------------------------------------------------------
-// Page-level styles (tab shell only — component styles live in configStyles.ts)
-// ---------------------------------------------------------------------------
+import { Text, makeStyles, tokens } from "@fluentui/react-components";
+import { ConfigPageContainer } from "../components/config/ConfigPageContainer";
const useStyles = makeStyles({
page: {
@@ -36,42 +23,14 @@ const useStyles = makeStyles({
header: {
marginBottom: tokens.spacingVerticalL,
},
- tabPanel: {
- display: "none",
- },
- tabPanelVisible: {
- display: "block",
- marginTop: tokens.spacingVerticalL,
- animationName: "fadeInUp",
- animationDuration: tokens.durationNormal,
- animationTimingFunction: tokens.curveDecelerateMid,
- animationFillMode: "both",
- },
infoText: {
color: tokens.colorNeutralForeground3,
fontStyle: "italic",
},
});
-
-type TabValue =
- | "jails"
- | "filters"
- | "actions"
- | "server"
- | "regex";
-
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
- const location = useLocation();
- const [tab, setTab] = useState("jails");
-
- useEffect(() => {
- const state = location.state as { tab?: string; jail?: string } | null;
- if (state?.tab === "jails") {
- setTab("jails");
- }
- }, [location.state]);
return (
@@ -85,40 +44,7 @@ export function ConfigPage(): React.JSX.Element {
- {
- setTab(d.value as TabValue);
- }}
- >
- Jails
- Filters
- Actions
- Server
- Regex Tester
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/frontend/src/pages/__tests__/ConfigPage.test.tsx b/frontend/src/pages/__tests__/ConfigPage.test.tsx
index 8e908aa..63271da 100644
--- a/frontend/src/pages/__tests__/ConfigPage.test.tsx
+++ b/frontend/src/pages/__tests__/ConfigPage.test.tsx
@@ -1,21 +1,14 @@
import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter } from "react-router-dom";
import { ConfigPage } from "../ConfigPage";
-// Mock all tab components to avoid deep render trees and API calls.
-vi.mock("../../components/config", () => ({
- JailsTab: ({ initialJail }: { initialJail?: string }) => (
-
- JailsTab
-
+// Mock the ConfigPageContainer to avoid router context issues in tests.
+vi.mock("../../components/config/ConfigPageContainer", () => ({
+ ConfigPageContainer: () => (
+ ConfigPageContainer
),
- FiltersTab: () => FiltersTab
,
- ActionsTab: () => ActionsTab
,
- ServerTab: () => ServerTab
,
- RegexTesterTab: () => RegexTesterTab
,
- ExportTab: () => ExportTab
,
}));
function renderPage() {
@@ -29,51 +22,20 @@ function renderPage() {
}
describe("ConfigPage", () => {
- it("renders the Jails tab by default", () => {
- renderPage();
- expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
- });
-
- it("switches to Filters tab when Filters tab is clicked", () => {
- renderPage();
- fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
- expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
- // Jails tab remains mounted (not removed from DOM), just hidden with CSS
- expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
- });
-
- it("switches to Actions tab when Actions tab is clicked", () => {
- renderPage();
- fireEvent.click(screen.getByRole("tab", { name: /actions/i }));
- expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
- });
-
- it("switches to Server tab when Server tab is clicked", () => {
- renderPage();
- fireEvent.click(screen.getByRole("tab", { name: /server/i }));
- expect(screen.getByTestId("server-tab")).toBeInTheDocument();
- });
-
- it("renders the page heading", () => {
+ it("renders the configuration page heading", () => {
renderPage();
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
});
- it("selects the Jails tab based on location state", () => {
- render(
-
-
-
-
- ,
- );
+ it("renders the ConfigPageContainer component", () => {
+ renderPage();
+ expect(screen.getByTestId("config-page-container")).toBeInTheDocument();
+ });
- const jailsTab = screen.getByTestId("jails-tab");
- expect(jailsTab).toBeInTheDocument();
- expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd");
+ it("renders the page description text", () => {
+ renderPage();
+ expect(
+ screen.getByText(/inspect and edit fail2ban jail configuration/i)
+ ).toBeInTheDocument();
});
});