fix(config): stabilize config hook callbacks to prevent action/filter flicker
This commit is contained in:
74
frontend/src/hooks/__tests__/useActionConfig.test.ts
Normal file
74
frontend/src/hooks/__tests__/useActionConfig.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import * as configApi from "../../api/config";
|
||||
import { useActionConfig } from "../useActionConfig";
|
||||
|
||||
vi.mock("../../api/config");
|
||||
|
||||
describe("useActionConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(configApi.fetchAction).mockResolvedValue({
|
||||
name: "iptables",
|
||||
filename: "iptables.conf",
|
||||
source_file: "/etc/fail2ban/action.d/iptables.conf",
|
||||
active: false,
|
||||
used_by_jails: [],
|
||||
before: null,
|
||||
after: null,
|
||||
actionstart: "",
|
||||
actionstop: "",
|
||||
actioncheck: "",
|
||||
actionban: "",
|
||||
actionunban: "",
|
||||
actionflush: "",
|
||||
definition_vars: {},
|
||||
init_vars: {},
|
||||
has_local_override: false,
|
||||
});
|
||||
vi.mocked(configApi.updateAction).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("calls fetchAction exactly once for stable name and rerenders", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ name }) => useActionConfig(name),
|
||||
{ initialProps: { name: "iptables" } },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rerender with the same action name; fetch should not be called again.
|
||||
rerender({ name: "iptables" });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls fetchAction again when name changes", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ name }) => useActionConfig(name),
|
||||
{ initialProps: { name: "iptables" } },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ name: "ssh" });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
72
frontend/src/hooks/__tests__/useFilterConfig.test.ts
Normal file
72
frontend/src/hooks/__tests__/useFilterConfig.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import * as configApi from "../../api/config";
|
||||
import { useFilterConfig } from "../useFilterConfig";
|
||||
|
||||
vi.mock("../../api/config");
|
||||
|
||||
describe("useFilterConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(configApi.fetchParsedFilter).mockResolvedValue({
|
||||
name: "sshd",
|
||||
filename: "sshd.conf",
|
||||
source_file: "/etc/fail2ban/filter.d/sshd.conf",
|
||||
active: false,
|
||||
used_by_jails: [],
|
||||
before: null,
|
||||
after: null,
|
||||
variables: {},
|
||||
prefregex: null,
|
||||
failregex: [],
|
||||
ignoreregex: [],
|
||||
maxlines: null,
|
||||
datepattern: null,
|
||||
journalmatch: null,
|
||||
has_local_override: false,
|
||||
});
|
||||
vi.mocked(configApi.updateParsedFilter).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("calls fetchParsedFilter only once for stable name", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ name }) => useFilterConfig(name),
|
||||
{ initialProps: { name: "sshd" } },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ name: "sshd" });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls fetchParsedFilter again when name changes", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ name }) => useFilterConfig(name),
|
||||
{ initialProps: { name: "sshd" } },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ name: "apache-auth" });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
* React hook for loading and updating a single parsed action config.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { fetchAction, updateAction } from "../api/config";
|
||||
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
|
||||
@@ -23,12 +24,18 @@ export interface UseActionConfigResult {
|
||||
* @param name - Action base name (e.g. ``"iptables"``).
|
||||
*/
|
||||
export function useActionConfig(name: string): UseActionConfigResult {
|
||||
const fetchFn = useCallback(() => fetchAction(name), [name]);
|
||||
const saveFn = useCallback(
|
||||
(update: ActionConfigUpdate) => updateAction(name, update),
|
||||
[name],
|
||||
);
|
||||
|
||||
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
|
||||
ActionConfig,
|
||||
ActionConfigUpdate
|
||||
>({
|
||||
fetchFn: () => fetchAction(name),
|
||||
saveFn: (update) => updateAction(name, update),
|
||||
fetchFn,
|
||||
saveFn,
|
||||
mergeOnSave: (prev, update) =>
|
||||
prev
|
||||
? {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* React hook for loading and updating a single parsed filter config.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { fetchParsedFilter, updateParsedFilter } from "../api/config";
|
||||
import type { FilterConfig, FilterConfigUpdate } from "../types/config";
|
||||
@@ -23,12 +24,18 @@ export interface UseFilterConfigResult {
|
||||
* @param name - Filter base name (e.g. ``"sshd"``).
|
||||
*/
|
||||
export function useFilterConfig(name: string): UseFilterConfigResult {
|
||||
const fetchFn = useCallback(() => fetchParsedFilter(name), [name]);
|
||||
const saveFn = useCallback(
|
||||
(update: FilterConfigUpdate) => updateParsedFilter(name, update),
|
||||
[name],
|
||||
);
|
||||
|
||||
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
|
||||
FilterConfig,
|
||||
FilterConfigUpdate
|
||||
>({
|
||||
fetchFn: () => fetchParsedFilter(name),
|
||||
saveFn: (update) => updateParsedFilter(name, update),
|
||||
fetchFn,
|
||||
saveFn,
|
||||
mergeOnSave: (prev, update) =>
|
||||
prev
|
||||
? {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* React hook for loading and updating a single parsed jail.d config file.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useConfigItem } from "./useConfigItem";
|
||||
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
|
||||
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
|
||||
@@ -21,12 +22,18 @@ export interface UseJailFileConfigResult {
|
||||
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
|
||||
*/
|
||||
export function useJailFileConfig(filename: string): UseJailFileConfigResult {
|
||||
const fetchFn = useCallback(() => fetchParsedJailFile(filename), [filename]);
|
||||
const saveFn = useCallback(
|
||||
(update: JailFileConfigUpdate) => updateParsedJailFile(filename, update),
|
||||
[filename],
|
||||
);
|
||||
|
||||
const { data, loading, error, refresh, save } = useConfigItem<
|
||||
JailFileConfig,
|
||||
JailFileConfigUpdate
|
||||
>({
|
||||
fetchFn: () => fetchParsedJailFile(filename),
|
||||
saveFn: (update) => updateParsedJailFile(filename, update),
|
||||
fetchFn,
|
||||
saveFn,
|
||||
mergeOnSave: (prev, update) =>
|
||||
update.jails != null && prev
|
||||
? { ...prev, jails: { ...prev.jails, ...update.jails } }
|
||||
|
||||
Reference in New Issue
Block a user