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.
|
* React hook for loading and updating a single parsed action config.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useConfigItem } from "./useConfigItem";
|
import { useConfigItem } from "./useConfigItem";
|
||||||
import { fetchAction, updateAction } from "../api/config";
|
import { fetchAction, updateAction } from "../api/config";
|
||||||
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
|
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
|
||||||
@@ -23,12 +24,18 @@ export interface UseActionConfigResult {
|
|||||||
* @param name - Action base name (e.g. ``"iptables"``).
|
* @param name - Action base name (e.g. ``"iptables"``).
|
||||||
*/
|
*/
|
||||||
export function useActionConfig(name: string): UseActionConfigResult {
|
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<
|
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
|
||||||
ActionConfig,
|
ActionConfig,
|
||||||
ActionConfigUpdate
|
ActionConfigUpdate
|
||||||
>({
|
>({
|
||||||
fetchFn: () => fetchAction(name),
|
fetchFn,
|
||||||
saveFn: (update) => updateAction(name, update),
|
saveFn,
|
||||||
mergeOnSave: (prev, update) =>
|
mergeOnSave: (prev, update) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* React hook for loading and updating a single parsed filter config.
|
* React hook for loading and updating a single parsed filter config.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useConfigItem } from "./useConfigItem";
|
import { useConfigItem } from "./useConfigItem";
|
||||||
import { fetchParsedFilter, updateParsedFilter } from "../api/config";
|
import { fetchParsedFilter, updateParsedFilter } from "../api/config";
|
||||||
import type { FilterConfig, FilterConfigUpdate } from "../types/config";
|
import type { FilterConfig, FilterConfigUpdate } from "../types/config";
|
||||||
@@ -23,12 +24,18 @@ export interface UseFilterConfigResult {
|
|||||||
* @param name - Filter base name (e.g. ``"sshd"``).
|
* @param name - Filter base name (e.g. ``"sshd"``).
|
||||||
*/
|
*/
|
||||||
export function useFilterConfig(name: string): UseFilterConfigResult {
|
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<
|
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
FilterConfigUpdate
|
FilterConfigUpdate
|
||||||
>({
|
>({
|
||||||
fetchFn: () => fetchParsedFilter(name),
|
fetchFn,
|
||||||
saveFn: (update) => updateParsedFilter(name, update),
|
saveFn,
|
||||||
mergeOnSave: (prev, update) =>
|
mergeOnSave: (prev, update) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* React hook for loading and updating a single parsed jail.d config file.
|
* React hook for loading and updating a single parsed jail.d config file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useConfigItem } from "./useConfigItem";
|
import { useConfigItem } from "./useConfigItem";
|
||||||
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
|
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
|
||||||
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
|
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
|
||||||
@@ -21,12 +22,18 @@ export interface UseJailFileConfigResult {
|
|||||||
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
|
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
|
||||||
*/
|
*/
|
||||||
export function useJailFileConfig(filename: string): UseJailFileConfigResult {
|
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<
|
const { data, loading, error, refresh, save } = useConfigItem<
|
||||||
JailFileConfig,
|
JailFileConfig,
|
||||||
JailFileConfigUpdate
|
JailFileConfigUpdate
|
||||||
>({
|
>({
|
||||||
fetchFn: () => fetchParsedJailFile(filename),
|
fetchFn,
|
||||||
saveFn: (update) => updateParsedJailFile(filename, update),
|
saveFn,
|
||||||
mergeOnSave: (prev, update) =>
|
mergeOnSave: (prev, update) =>
|
||||||
update.jails != null && prev
|
update.jails != null && prev
|
||||||
? { ...prev, jails: { ...prev.jails, ...update.jails } }
|
? { ...prev, jails: { ...prev.jails, ...update.jails } }
|
||||||
|
|||||||
Reference in New Issue
Block a user