chore: commit local changes

This commit is contained in:
2026-03-22 10:07:44 +01:00
parent 96370ee6aa
commit e2876fc35c
26 changed files with 578 additions and 1379 deletions

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useConfigItem } from "../useConfigItem";
describe("useConfigItem", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("loads data and sets loading state", async () => {
const fetchFn = vi.fn().mockResolvedValue("hello");
const saveFn = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
expect(result.current.loading).toBe(true);
await act(async () => {
await Promise.resolve();
});
expect(fetchFn).toHaveBeenCalled();
expect(result.current.data).toBe("hello");
expect(result.current.loading).toBe(false);
});
it("sets error if fetch rejects", async () => {
const fetchFn = vi.fn().mockRejectedValue(new Error("nope"));
const saveFn = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
await act(async () => {
await Promise.resolve();
});
expect(result.current.error).toBe("nope");
expect(result.current.loading).toBe(false);
});
it("save updates data when mergeOnSave is provided", async () => {
const fetchFn = vi.fn().mockResolvedValue({ value: 1 });
const saveFn = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useConfigItem<{ value: number }, { delta: number }>({
fetchFn,
saveFn,
mergeOnSave: (prev, update) =>
prev ? { ...prev, value: prev.value + update.delta } : prev,
})
);
await act(async () => {
await Promise.resolve();
});
expect(result.current.data).toEqual({ value: 1 });
await act(async () => {
await result.current.save({ delta: 2 });
});
expect(saveFn).toHaveBeenCalledWith({ delta: 2 });
expect(result.current.data).toEqual({ value: 3 });
});
it("saveError is set when save fails", async () => {
const fetchFn = vi.fn().mockResolvedValue("ok");
const saveFn = vi.fn().mockRejectedValue(new Error("save failed"));
const { result } = renderHook(() => useConfigItem<string, string>({ fetchFn, saveFn }));
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await expect(result.current.save("test")).rejects.toThrow("save failed");
});
expect(result.current.saveError).toBe("save failed");
});
});

View File

@@ -2,7 +2,7 @@
* React hook for loading and updating a single parsed action config.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useConfigItem } from "./useConfigItem";
import { fetchAction, updateAction } from "../api/config";
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
@@ -23,67 +23,28 @@ export interface UseActionConfigResult {
* @param name - Action base name (e.g. ``"iptables"``).
*/
export function useActionConfig(name: string): UseActionConfigResult {
const [config, setConfig] = useState<ActionConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
ActionConfig,
ActionConfigUpdate
>({
fetchFn: () => fetchAction(name),
saveFn: (update) => updateAction(name, update),
mergeOnSave: (prev, update) =>
prev
? {
...prev,
...Object.fromEntries(Object.entries(update).filter(([, v]) => v != null)),
}
: prev,
});
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchAction(name)
.then((data) => {
if (!ctrl.signal.aborted) {
setConfig(data);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load action config");
setLoading(false);
}
});
}, [name]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const save = useCallback(
async (update: ActionConfigUpdate): Promise<void> => {
setSaving(true);
setSaveError(null);
try {
await updateAction(name, update);
setConfig((prev) =>
prev
? {
...prev,
...Object.fromEntries(
Object.entries(update).filter(([, v]) => v !== null && v !== undefined)
),
}
: prev
);
} catch (err: unknown) {
setSaveError(err instanceof Error ? err.message : "Failed to save action config");
throw err;
} finally {
setSaving(false);
}
},
[name]
);
return { config, loading, error, saving, saveError, refresh: load, save };
return {
config: data,
loading,
error,
saving,
saveError,
refresh,
save,
};
}

View File

@@ -0,0 +1,84 @@
/**
* Generic config hook for loading and saving a single entity.
*/
import { useCallback, useEffect, useRef, useState } from "react";
export interface UseConfigItemResult<T, U> {
data: T | null;
loading: boolean;
error: string | null;
saving: boolean;
saveError: string | null;
refresh: () => void;
save: (update: U) => Promise<void>;
}
export interface UseConfigItemOptions<T, U> {
fetchFn: (signal: AbortSignal) => Promise<T>;
saveFn: (update: U) => Promise<void>;
mergeOnSave?: (prev: T | null, update: U) => T | null;
}
export function useConfigItem<T, U>(
options: UseConfigItemOptions<T, U>
): UseConfigItemResult<T, U> {
const { fetchFn, saveFn, mergeOnSave } = options;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const refresh = useCallback((): void => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetchFn(controller.signal)
.then((nextData) => {
if (controller.signal.aborted) return;
setData(nextData);
setLoading(false);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to load data");
setLoading(false);
});
}, [fetchFn]);
useEffect(() => {
refresh();
return () => {
abortRef.current?.abort();
};
}, [refresh]);
const save = useCallback(
async (update: U): Promise<void> => {
setSaving(true);
setSaveError(null);
try {
await saveFn(update);
if (mergeOnSave) {
setData((prevData) => mergeOnSave(prevData, update));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to save data";
setSaveError(message);
throw err;
} finally {
setSaving(false);
}
},
[saveFn, mergeOnSave]
);
return { data, loading, error, saving, saveError, refresh, save };
}

View File

@@ -2,7 +2,7 @@
* React hook for loading and updating a single parsed filter config.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useConfigItem } from "./useConfigItem";
import { fetchParsedFilter, updateParsedFilter } from "../api/config";
import type { FilterConfig, FilterConfigUpdate } from "../types/config";
@@ -23,69 +23,28 @@ export interface UseFilterConfigResult {
* @param name - Filter base name (e.g. ``"sshd"``).
*/
export function useFilterConfig(name: string): UseFilterConfigResult {
const [config, setConfig] = useState<FilterConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const { data, loading, error, saving, saveError, refresh, save } = useConfigItem<
FilterConfig,
FilterConfigUpdate
>({
fetchFn: () => fetchParsedFilter(name),
saveFn: (update) => updateParsedFilter(name, update),
mergeOnSave: (prev, update) =>
prev
? {
...prev,
...Object.fromEntries(Object.entries(update).filter(([, v]) => v != null)),
}
: prev,
});
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchParsedFilter(name)
.then((data) => {
if (!ctrl.signal.aborted) {
setConfig(data);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load filter config");
setLoading(false);
}
});
}, [name]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const save = useCallback(
async (update: FilterConfigUpdate): Promise<void> => {
setSaving(true);
setSaveError(null);
try {
await updateParsedFilter(name, update);
// Optimistically update local state so the form reflects changes
// without a full reload.
setConfig((prev) =>
prev
? {
...prev,
...Object.fromEntries(
Object.entries(update).filter(([, v]) => v !== null && v !== undefined)
),
}
: prev
);
} catch (err: unknown) {
setSaveError(err instanceof Error ? err.message : "Failed to save filter config");
throw err;
} finally {
setSaving(false);
}
},
[name]
);
return { config, loading, error, saving, saveError, refresh: load, save };
return {
config: data,
loading,
error,
saving,
saveError,
refresh,
save,
};
}

View File

@@ -2,7 +2,7 @@
* React hook for loading and updating a single parsed jail.d config file.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useConfigItem } from "./useConfigItem";
import { fetchParsedJailFile, updateParsedJailFile } from "../api/config";
import type { JailFileConfig, JailFileConfigUpdate } from "../types/config";
@@ -21,56 +21,23 @@ export interface UseJailFileConfigResult {
* @param filename - Filename including extension (e.g. ``"sshd.conf"``).
*/
export function useJailFileConfig(filename: string): UseJailFileConfigResult {
const [config, setConfig] = useState<JailFileConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const { data, loading, error, refresh, save } = useConfigItem<
JailFileConfig,
JailFileConfigUpdate
>({
fetchFn: () => fetchParsedJailFile(filename),
saveFn: (update) => updateParsedJailFile(filename, update),
mergeOnSave: (prev, update) =>
update.jails != null && prev
? { ...prev, jails: { ...prev.jails, ...update.jails } }
: prev,
});
const load = useCallback((): void => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
fetchParsedJailFile(filename)
.then((data) => {
if (!ctrl.signal.aborted) {
setConfig(data);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
setError(err instanceof Error ? err.message : "Failed to load jail file config");
setLoading(false);
}
});
}, [filename]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const save = useCallback(
async (update: JailFileConfigUpdate): Promise<void> => {
try {
await updateParsedJailFile(filename, update);
// Optimistically merge updated jails into local state.
if (update.jails != null) {
setConfig((prev) =>
prev ? { ...prev, jails: { ...prev.jails, ...update.jails } } : prev
);
}
} catch (err: unknown) {
throw err instanceof Error ? err : new Error("Failed to save jail file config");
}
},
[filename]
);
return { config, loading, error, refresh: load, save };
return {
config: data,
loading,
error,
refresh,
save,
};
}