From 2105f8b435abd0e16943c11f333a1cef9eedcfd9 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 18 Apr 2026 21:20:45 +0200 Subject: [PATCH] Task 11: Remove direct API calls from components --- Docs/Tasks.md | 2 + frontend/src/components/config/ActionsTab.tsx | 107 +++++--------- .../components/config/ActivateJailDialog.tsx | 15 +- .../components/config/AssignActionDialog.tsx | 38 +++-- .../components/config/AssignFilterDialog.tsx | 38 +++-- .../components/config/CreateActionDialog.tsx | 10 +- .../components/config/CreateFilterDialog.tsx | 6 +- .../components/config/CreateJailDialog.tsx | 6 +- frontend/src/components/config/FiltersTab.tsx | 84 ++++------- frontend/src/components/config/JailsTab.tsx | 132 ++++++++++-------- .../components/config/ServerHealthSection.tsx | 38 ++--- .../__tests__/ActivateJailDialog.test.tsx | 25 +++- .../__tests__/AssignFilterDialog.test.tsx | 6 + .../__tests__/CreateFilterDialog.test.tsx | 1 + frontend/src/hooks/useActionList.ts | 91 ++++++++++++ frontend/src/hooks/useActionRawFile.ts | 32 +++++ frontend/src/hooks/useFilterList.ts | 82 +++++++++++ frontend/src/hooks/useFilterRawFile.ts | 32 +++++ frontend/src/hooks/useJailAdmin.ts | 123 ++++++++++++++++ frontend/src/hooks/useJailConfigOperations.ts | 57 ++++++++ frontend/src/hooks/useServerHealth.ts | 53 +++++++ 21 files changed, 712 insertions(+), 266 deletions(-) create mode 100644 frontend/src/hooks/useActionList.ts create mode 100644 frontend/src/hooks/useActionRawFile.ts create mode 100644 frontend/src/hooks/useFilterList.ts create mode 100644 frontend/src/hooks/useFilterRawFile.ts create mode 100644 frontend/src/hooks/useJailAdmin.ts create mode 100644 frontend/src/hooks/useJailConfigOperations.ts create mode 100644 frontend/src/hooks/useServerHealth.ts diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 4180693..662465d 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -239,6 +239,8 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. **Docs changes needed:** None. This task brings the code into conformance with the documented rules ("Components receive data via props only — they never call the API directly"). +**Status:** Completed. + **Why this is needed:** Components that call the API directly are impossible to unit-test without mocking the network, cannot be reused with alternate data sources, and violate the layered architecture. The hook layer exists precisely to own data fetching so that the component layer stays declarative and testable. --- diff --git a/frontend/src/components/config/ActionsTab.tsx b/frontend/src/components/config/ActionsTab.tsx index 297d0bf..0e2fc53 100644 --- a/frontend/src/components/config/ActionsTab.tsx +++ b/frontend/src/components/config/ActionsTab.tsx @@ -10,7 +10,7 @@ * creating a new ``action.d/*.local`` file. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Button, Field, @@ -22,13 +22,10 @@ import { tokens, } from "@fluentui/react-components"; import { Add24Regular, Delete24Regular, LinkEdit24Regular } from "@fluentui/react-icons"; -import { - fetchActionFile, - updateActionFile, -} from "../../api/file_config"; -import { fetchActions, removeActionFromJail } from "../../api/config"; -import type { ActionConfig, ConfFileUpdateRequest } from "../../types/config"; +import type { ActionConfig } from "../../types/config"; import { ActionForm } from "./ActionForm"; +import { useActionList } from "../../hooks/useActionList"; +import { useActionRawFile } from "../../hooks/useActionRawFile"; import { AssignActionDialog } from "./AssignActionDialog"; import { ConfigListDetail } from "./ConfigListDetail"; import { CreateActionDialog } from "./CreateActionDialog"; @@ -55,7 +52,7 @@ function actionBadgeLabel(a: ActionConfig): string { interface ActionDetailProps { action: ActionConfig; onAssignClick: () => void; - onRemovedFromJail: () => void; + onRemovedFromJail: (jailName: string) => Promise; } /** @@ -73,27 +70,15 @@ function ActionDetail({ const styles = useConfigStyles(); const [removingJail, setRemovingJail] = useState(null); const [removeError, setRemoveError] = useState(null); - - const fetchRaw = useCallback(async (): Promise => { - const result = await fetchActionFile(action.name); - return result.content; - }, [action.name]); - - const saveRaw = useCallback( - async (content: string): Promise => { - const req: ConfFileUpdateRequest = { content }; - await updateActionFile(action.name, req); - }, - [action.name], - ); + const { fetchRawContent, saveRawContent } = useActionRawFile(action.name); const handleRemoveFromJail = useCallback( (jailName: string): void => { setRemovingJail(jailName); setRemoveError(null); - removeActionFromJail(jailName, action.name) + onRemovedFromJail(jailName) .then(() => { - onRemovedFromJail(); + // No-op: parent refreshes the list. }) .catch((err: unknown) => { setRemoveError( @@ -104,7 +89,7 @@ function ActionDetail({ setRemovingJail(null); }); }, - [action.name, onRemovedFromJail], + [onRemovedFromJail], ); return ( @@ -174,8 +159,8 @@ function ActionDetail({ {/* Raw config */}
@@ -193,42 +178,18 @@ function ActionDetail({ * @returns JSX element. */ export function ActionsTab(): React.JSX.Element { - const [actions, setActions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { + actions, + loading, + error, + refresh, + removeActionFromJail, + createAction, + assignActionToJail, + } = useActionList(); const [selectedName, setSelectedName] = useState(null); const [assignOpen, setAssignOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const abortRef = useRef(null); - - const loadActions = useCallback((): void => { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - setLoading(true); - setError(null); - - fetchActions() - .then((resp) => { - if (!ctrl.signal.aborted) { - setActions(resp.actions); - setLoading(false); - } - }) - .catch((err: unknown) => { - if (!ctrl.signal.aborted) { - setError(err instanceof Error ? err.message : "Failed to load actions"); - setLoading(false); - } - }); - }, []); - - useEffect(() => { - loadActions(); - return (): void => { - abortRef.current?.abort(); - }; - }, [loadActions]); /** The full ActionConfig for the currently selected name. */ const selectedAction = useMemo( @@ -238,22 +199,26 @@ export function ActionsTab(): React.JSX.Element { const handleAssigned = useCallback((): void => { setAssignOpen(false); - // Refresh action list so active status is up-to-date. - loadActions(); - }, [loadActions]); + refresh(); + }, [refresh]); const handleCreated = useCallback( (newAction: ActionConfig): void => { setCreateOpen(false); - setActions((prev) => [...prev, newAction]); setSelectedName(newAction.name); + refresh(); }, - [], + [refresh], ); - const handleRemovedFromJail = useCallback((): void => { - loadActions(); - }, [loadActions]); + const handleRemovedFromJail = useCallback( + async (jailName: string): Promise => { + if (!selectedName) return; + await removeActionFromJail(jailName, selectedName); + refresh(); + }, + [removeActionFromJail, refresh, selectedName], + ); if (loading) { return ( @@ -292,8 +257,8 @@ export function ActionsTab(): React.JSX.Element { itemBadgeLabel={actionBadgeLabel} selectedName={selectedName} onSelect={setSelectedName} - loading={false} - error={null} + loading={loading} + error={error} listHeader={listHeader} > {selectedAction !== null && ( @@ -311,11 +276,15 @@ export function ActionsTab(): React.JSX.Element { open={assignOpen} onClose={() => { setAssignOpen(false); }} onAssigned={handleAssigned} + onAssign={async (jailName, payload, reload) => { + await assignActionToJail(jailName, payload, reload); + }} /> { setCreateOpen(false); }} + onCreateAction={createAction} onCreate={handleCreated} /> diff --git a/frontend/src/components/config/ActivateJailDialog.tsx b/frontend/src/components/config/ActivateJailDialog.tsx index d31fc6d..3b385d8 100644 --- a/frontend/src/components/config/ActivateJailDialog.tsx +++ b/frontend/src/components/config/ActivateJailDialog.tsx @@ -27,11 +27,12 @@ import { Text, tokens, } from "@fluentui/react-components"; -import { activateJail, validateJailConfig } from "../../api/config"; import type { ActivateJailRequest, + JailActivationResponse, InactiveJail, JailValidationIssue, + JailValidationResult, } from "../../types/config"; import { ApiError } from "../../api/client"; @@ -48,6 +49,10 @@ export interface ActivateJailDialogProps { onClose: () => void; /** Called after the jail has been successfully activated. */ onActivated: () => void; + /** Validates the inactive jail configuration before activation. */ + onValidate: () => Promise; + /** Activates the jail with optional override fields. */ + onActivate: (payload: ActivateJailRequest) => Promise; } // --------------------------------------------------------------------------- @@ -68,6 +73,8 @@ export function ActivateJailDialog({ open, onClose, onActivated, + onValidate, + onActivate, }: ActivateJailDialogProps): React.JSX.Element { const [bantime, setBantime] = useState(""); const [findtime, setFindtime] = useState(""); @@ -103,7 +110,7 @@ export function ActivateJailDialog({ setValidationIssues([]); setValidationWarnings([]); - validateJailConfig(jail.name) + onValidate() .then((result) => { setValidationIssues(result.issues); }) @@ -114,7 +121,7 @@ export function ActivateJailDialog({ .finally(() => { setValidating(false); }); - }, [open, jail]); + }, [open, jail, onValidate]); const handleClose = (): void => { if (submitting) return; @@ -143,7 +150,7 @@ export function ActivateJailDialog({ setSubmitting(true); setError(null); - activateJail(jail.name, overrides) + onActivate(overrides) .then((result) => { if (!result.active) { if (result.recovered === true) { diff --git a/frontend/src/components/config/AssignActionDialog.tsx b/frontend/src/components/config/AssignActionDialog.tsx index 7045186..584a37d 100644 --- a/frontend/src/components/config/AssignActionDialog.tsx +++ b/frontend/src/components/config/AssignActionDialog.tsx @@ -23,10 +23,8 @@ import { Text, tokens, } from "@fluentui/react-components"; -import { assignActionToJail } from "../../api/config"; -import { fetchJails } from "../../api/jails"; +import { useJails } from "../../hooks/useJails"; import type { AssignActionRequest } from "../../types/config"; -import type { JailSummary } from "../../types/jail"; import { ApiError } from "../../api/client"; // --------------------------------------------------------------------------- @@ -45,6 +43,8 @@ export interface AssignActionDialogProps { onClose: () => void; /** Called after the action has been successfully assigned. */ onAssigned: () => void; + /** Assigns the action to a running jail. */ + onAssign: (jailName: string, payload: AssignActionRequest, reload: boolean) => Promise; } // --------------------------------------------------------------------------- @@ -66,35 +66,29 @@ export function AssignActionDialog({ open, onClose, onAssigned, + onAssign, }: AssignActionDialogProps): React.JSX.Element { - const [jails, setJails] = useState([]); - const [jailsLoading, setJailsLoading] = useState(false); + const { jails, loading: jailsLoading, error: jailsError } = useJails(); const [selectedJail, setSelectedJail] = useState(""); const [reload, setReload] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - // Fetch running jails whenever the dialog opens. + const activeJails = jails.filter((j) => j.enabled); + useEffect(() => { if (!open) return; - - setJailsLoading(true); setError(null); setSelectedJail(""); setReload(false); - - fetchJails() - .then((resp) => { - setJails(resp.jails.filter((j) => j.enabled)); - }) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Failed to load jails."); - }) - .finally(() => { - setJailsLoading(false); - }); }, [open]); + useEffect(() => { + if (jailsError && open) { + setError(jailsError); + } + }, [jailsError, open]); + const handleClose = useCallback((): void => { if (submitting) return; setError(null); @@ -108,7 +102,7 @@ export function AssignActionDialog({ setSubmitting(true); setError(null); - assignActionToJail(selectedJail, req, reload) + onAssign(selectedJail, req, reload) .then(() => { onAssigned(); }) @@ -120,7 +114,7 @@ export function AssignActionDialog({ .finally(() => { setSubmitting(false); }); - }, [actionName, selectedJail, reload, submitting, onAssigned]); + }, [actionName, selectedJail, reload, submitting, onAssigned, onAssign]); const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; @@ -170,7 +164,7 @@ export function AssignActionDialog({ - {jails.map((j) => ( + {activeJails.map((j) => ( diff --git a/frontend/src/components/config/AssignFilterDialog.tsx b/frontend/src/components/config/AssignFilterDialog.tsx index cb704d2..37c89a0 100644 --- a/frontend/src/components/config/AssignFilterDialog.tsx +++ b/frontend/src/components/config/AssignFilterDialog.tsx @@ -23,10 +23,8 @@ import { Text, tokens, } from "@fluentui/react-components"; -import { assignFilterToJail } from "../../api/config"; -import { fetchJails } from "../../api/jails"; +import { useJails } from "../../hooks/useJails"; import type { AssignFilterRequest } from "../../types/config"; -import type { JailSummary } from "../../types/jail"; import { ApiError } from "../../api/client"; // --------------------------------------------------------------------------- @@ -45,6 +43,8 @@ export interface AssignFilterDialogProps { onClose: () => void; /** Called after the filter has been successfully assigned. */ onAssigned: () => void; + /** Assigns the filter to a running jail. */ + onAssign: (jailName: string, payload: AssignFilterRequest, reload: boolean) => Promise; } // --------------------------------------------------------------------------- @@ -66,35 +66,29 @@ export function AssignFilterDialog({ open, onClose, onAssigned, + onAssign, }: AssignFilterDialogProps): React.JSX.Element { - const [jails, setJails] = useState([]); - const [jailsLoading, setJailsLoading] = useState(false); + const { jails, loading: jailsLoading, error: jailsError } = useJails(); const [selectedJail, setSelectedJail] = useState(""); const [reload, setReload] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - // Fetch running jails whenever the dialog opens. + const activeJails = jails.filter((j) => j.enabled); + useEffect(() => { if (!open) return; - - setJailsLoading(true); setError(null); setSelectedJail(""); setReload(false); - - fetchJails() - .then((resp) => { - setJails(resp.jails.filter((j) => j.enabled)); - }) - .catch((err: unknown) => { - setError(err instanceof Error ? err.message : "Failed to load jails."); - }) - .finally(() => { - setJailsLoading(false); - }); }, [open]); + useEffect(() => { + if (jailsError && open) { + setError(jailsError); + } + }, [jailsError, open]); + const handleClose = useCallback((): void => { if (submitting) return; setError(null); @@ -108,7 +102,7 @@ export function AssignFilterDialog({ setSubmitting(true); setError(null); - assignFilterToJail(selectedJail, req, reload) + onAssign(selectedJail, req, reload) .then(() => { onAssigned(); }) @@ -120,7 +114,7 @@ export function AssignFilterDialog({ .finally(() => { setSubmitting(false); }); - }, [filterName, selectedJail, reload, submitting, onAssigned]); + }, [filterName, selectedJail, reload, submitting, onAssigned, onAssign]); const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; @@ -171,7 +165,7 @@ export function AssignFilterDialog({ - {jails.map((j) => ( + {activeJails.map((j) => ( diff --git a/frontend/src/components/config/CreateActionDialog.tsx b/frontend/src/components/config/CreateActionDialog.tsx index c162819..52fa206 100644 --- a/frontend/src/components/config/CreateActionDialog.tsx +++ b/frontend/src/components/config/CreateActionDialog.tsx @@ -23,7 +23,6 @@ import { Textarea, tokens, } from "@fluentui/react-components"; -import { createAction } from "../../api/config"; import type { ActionConfig, ActionCreateRequest } from "../../types/config"; import { ApiError } from "../../api/client"; @@ -36,6 +35,12 @@ export interface CreateActionDialogProps { open: boolean; /** Called when the dialog should close without taking action. */ onClose: () => void; + /** + * Called when the form is submitted with valid dialog data. + * + * @param payload - Create request payload. + */ + onCreateAction: (payload: ActionCreateRequest) => Promise; /** * Called after the action has been successfully created. * @@ -60,6 +65,7 @@ export interface CreateActionDialogProps { export function CreateActionDialog({ open, onClose, + onCreateAction, onCreate, }: CreateActionDialogProps): React.JSX.Element { const [name, setName] = useState(""); @@ -96,7 +102,7 @@ export function CreateActionDialog({ setSubmitting(true); setError(null); - createAction(req) + onCreateAction(req) .then((action) => { onCreate(action); }) diff --git a/frontend/src/components/config/CreateFilterDialog.tsx b/frontend/src/components/config/CreateFilterDialog.tsx index cabd28d..0f499d6 100644 --- a/frontend/src/components/config/CreateFilterDialog.tsx +++ b/frontend/src/components/config/CreateFilterDialog.tsx @@ -23,7 +23,6 @@ import { Textarea, tokens, } from "@fluentui/react-components"; -import { createFilter } from "../../api/config"; import type { FilterConfig, FilterCreateRequest } from "../../types/config"; import { ApiError } from "../../api/client"; @@ -36,6 +35,8 @@ export interface CreateFilterDialogProps { open: boolean; /** Called when the dialog should close without taking action. */ onClose: () => void; + /** Called when the form is submitted with valid dialog data. */ + onCreateFilter: (payload: FilterCreateRequest) => Promise; /** * Called after the filter has been successfully created. * @@ -73,6 +74,7 @@ function splitLines(value: string): string[] { export function CreateFilterDialog({ open, onClose, + onCreateFilter, onCreate, }: CreateFilterDialogProps): React.JSX.Element { const [name, setName] = useState(""); @@ -109,7 +111,7 @@ export function CreateFilterDialog({ setSubmitting(true); setError(null); - createFilter(req) + onCreateFilter(req) .then((filter) => { onCreate(filter); }) diff --git a/frontend/src/components/config/CreateJailDialog.tsx b/frontend/src/components/config/CreateJailDialog.tsx index 455a8b0..a33ecf6 100644 --- a/frontend/src/components/config/CreateJailDialog.tsx +++ b/frontend/src/components/config/CreateJailDialog.tsx @@ -22,7 +22,6 @@ import { Text, tokens, } from "@fluentui/react-components"; -import { createJailConfigFile } from "../../api/file_config"; import type { ConfFileCreateRequest } from "../../types/config"; import { ApiError } from "../../api/client"; @@ -35,6 +34,8 @@ export interface CreateJailDialogProps { open: boolean; /** Called when the dialog should close without taking action. */ onClose: () => void; + /** Called when the form is submitted with valid dialog data. */ + onCreateJail: (payload: ConfFileCreateRequest) => Promise; /** Called after the jail config file has been successfully created. */ onCreated: () => void; } @@ -56,6 +57,7 @@ export interface CreateJailDialogProps { export function CreateJailDialog({ open, onClose, + onCreateJail, onCreated, }: CreateJailDialogProps): React.JSX.Element { const [name, setName] = useState(""); @@ -87,7 +89,7 @@ export function CreateJailDialog({ setSubmitting(true); setError(null); - createJailConfigFile(req) + onCreateJail(req) .then(() => { onCreated(); }) diff --git a/frontend/src/components/config/FiltersTab.tsx b/frontend/src/components/config/FiltersTab.tsx index 4316853..078a05f 100644 --- a/frontend/src/components/config/FiltersTab.tsx +++ b/frontend/src/components/config/FiltersTab.tsx @@ -10,7 +10,7 @@ * creating a new ``filter.d/*.local`` file. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Button, Field, @@ -22,14 +22,14 @@ import { tokens, } from "@fluentui/react-components"; import { Add24Regular, LinkEdit24Regular } from "@fluentui/react-icons"; -import { fetchFilterFile, updateFilterFile } from "../../api/file_config"; -import { fetchFilters } from "../../api/config"; -import type { ConfFileUpdateRequest, FilterConfig } from "../../types/config"; +import type { FilterConfig } from "../../types/config"; import { AssignFilterDialog } from "./AssignFilterDialog"; import { ConfigListDetail } from "./ConfigListDetail"; import { CreateFilterDialog } from "./CreateFilterDialog"; import { FilterForm } from "./FilterForm"; import { RawConfigSection } from "./RawConfigSection"; +import { useFilterList } from "../../hooks/useFilterList"; +import { useFilterRawFile } from "../../hooks/useFilterRawFile"; import { useConfigStyles } from "./configStyles"; // --------------------------------------------------------------------------- @@ -69,19 +69,7 @@ function FilterDetail({ onAssignClick, }: FilterDetailProps): React.JSX.Element { const styles = useConfigStyles(); - - const fetchRaw = useCallback(async (): Promise => { - const result = await fetchFilterFile(filter.name); - return result.content; - }, [filter.name]); - - const saveRaw = useCallback( - async (content: string): Promise => { - const req: ConfFileUpdateRequest = { content }; - await updateFilterFile(filter.name, req); - }, - [filter.name], - ); + const { fetchRawContent, saveRawContent } = useFilterRawFile(filter.name); return (
@@ -114,8 +102,8 @@ function FilterDetail({ {/* Raw config */}
@@ -133,42 +121,17 @@ function FilterDetail({ * @returns JSX element. */ export function FiltersTab(): React.JSX.Element { - const [filters, setFilters] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { + filters, + loading, + error, + refresh, + createFilter, + assignFilterToJail, + } = useFilterList(); const [selectedName, setSelectedName] = useState(null); const [assignOpen, setAssignOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const abortRef = useRef(null); - - const loadFilters = useCallback((): void => { - abortRef.current?.abort(); - const ctrl = new AbortController(); - abortRef.current = ctrl; - setLoading(true); - setError(null); - - fetchFilters() - .then((resp) => { - if (!ctrl.signal.aborted) { - setFilters(resp.filters); - setLoading(false); - } - }) - .catch((err: unknown) => { - if (!ctrl.signal.aborted) { - setError(err instanceof Error ? err.message : "Failed to load filters"); - setLoading(false); - } - }); - }, []); - - useEffect(() => { - loadFilters(); - return (): void => { - abortRef.current?.abort(); - }; - }, [loadFilters]); /** The full FilterConfig for the currently selected name. */ const selectedFilter = useMemo( @@ -178,17 +141,16 @@ export function FiltersTab(): React.JSX.Element { const handleAssigned = useCallback((): void => { setAssignOpen(false); - // Refresh filter list so active status is up-to-date. - loadFilters(); - }, [loadFilters]); + refresh(); + }, [refresh]); const handleCreated = useCallback( (newFilter: FilterConfig): void => { setCreateOpen(false); - setFilters((prev) => [...prev, newFilter]); setSelectedName(newFilter.name); + refresh(); }, - [], + [refresh], ); if (loading) { @@ -228,8 +190,8 @@ export function FiltersTab(): React.JSX.Element { itemBadgeLabel={filterBadgeLabel} selectedName={selectedName} onSelect={setSelectedName} - loading={false} - error={null} + loading={loading} + error={error} listHeader={listHeader} > {selectedFilter !== null && ( @@ -246,11 +208,15 @@ export function FiltersTab(): React.JSX.Element { open={assignOpen} onClose={() => { setAssignOpen(false); }} onAssigned={handleAssigned} + onAssign={async (jailName, payload, reload) => { + await assignFilterToJail(jailName, payload, reload); + }} /> { setCreateOpen(false); }} + onCreateFilter={createFilter} onCreate={handleCreated} /> diff --git a/frontend/src/components/config/JailsTab.tsx b/frontend/src/components/config/JailsTab.tsx index 30d75b7..61708b0 100644 --- a/frontend/src/components/config/JailsTab.tsx +++ b/frontend/src/components/config/JailsTab.tsx @@ -32,19 +32,8 @@ import { Play24Regular, } from "@fluentui/react-icons"; import { ApiError } from "../../api/client"; -import { - addLogPath, - deactivateJail, - deleteJailLocalOverride, - deleteLogPath, - fetchInactiveJails, - fetchJailConfigFileContent, - updateJailConfigFile, - validateJailConfig, -} from "../../api/config"; import type { AddLogPathRequest, - ConfFileUpdateRequest, InactiveJail, JailConfig, JailConfigUpdate, @@ -54,6 +43,8 @@ import type { import { useAutoSave } from "../../hooks/useAutoSave"; import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { useJailConfigs } from "../../hooks/useConfig"; +import { useJailAdmin } from "../../hooks/useJailAdmin"; +import { useJailConfigOperations } from "../../hooks/useJailConfigOperations"; import { ActivateJailDialog } from "./ActivateJailDialog"; import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { ConfigListDetail } from "./ConfigListDetail"; @@ -166,12 +157,19 @@ function JailConfigDetail({ esc0?.overall_jails ?? false, ); + const { + addLogPath: addLogPathToJail, + deleteLogPath: deleteJailLogPath, + fetchRawContent, + saveRawContent, + } = useJailConfigOperations(jail.name); + const handleDeleteLogPath = useCallback( async (path: string) => { setDeletingPath(path); setMsg(null); try { - await deleteLogPath(jail.name, path); + await deleteJailLogPath(path); setLogPaths((prev) => prev.filter((p) => p !== path)); setMsg({ text: `Removed log path: ${path}`, ok: true }); } catch (err: unknown) { @@ -183,7 +181,7 @@ function JailConfigDetail({ setDeletingPath(null); } }, - [jail.name], + [deleteJailLogPath], ); const handleAddLogPath = useCallback(async () => { @@ -193,7 +191,7 @@ function JailConfigDetail({ setMsg(null); try { const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail }; - await addLogPath(jail.name, req); + await addLogPathToJail(req); setLogPaths((prev) => [...prev, trimmed]); setNewLogPath(""); setMsg({ text: `Added log path: ${trimmed}`, ok: true }); @@ -205,7 +203,7 @@ function JailConfigDetail({ } finally { setAddingLogPath(false); } - }, [jail.name, newLogPath, newLogPathTail]); + }, [addLogPathToJail, newLogPath, newLogPathTail]); const autoSavePayload = useMemo( () => ({ @@ -249,16 +247,14 @@ function JailConfigDetail({ // Raw config file fetch/save helpers — uses jail.d/.conf convention. const fetchRaw = useCallback(async (): Promise => { - const result = await fetchJailConfigFileContent(`${jail.name}.conf`); - return result.content; - }, [jail.name]); + return await fetchRawContent(); + }, [fetchRawContent]); const saveRaw = useCallback( async (content: string): Promise => { - const req: ConfFileUpdateRequest = { content }; - await updateJailConfigFile(`${jail.name}.conf`, req); + await saveRawContent(content); }, - [jail.name], + [saveRawContent], ); return ( @@ -629,6 +625,7 @@ interface InactiveJailDetailProps { onActivate: () => void; /** Called when the user requests removal of the .local override file. */ onDeactivate?: () => void; + onValidate: () => Promise; } /** @@ -646,6 +643,7 @@ function InactiveJailDetail({ jail, onActivate, onDeactivate, + onValidate, }: InactiveJailDetailProps): React.JSX.Element { const styles = useConfigStyles(); const [validating, setValidating] = useState(false); @@ -654,11 +652,11 @@ function InactiveJailDetail({ const handleValidate = useCallback((): void => { setValidating(true); setValidationResult(null); - validateJailConfig(jail.name) + onValidate() .then((result) => { setValidationResult(result); }) .catch(() => { /* validation call failed — ignore */ }) .finally(() => { setValidating(false); }); - }, [jail.name]); + }, [onValidate]); const blockingIssues: JailValidationIssue[] = validationResult?.issues.filter((i) => i.field !== "logpath") ?? []; @@ -767,51 +765,51 @@ export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element { const { jails, loading, error, refresh, updateJail } = useJailConfigs(); const { activeJails } = useConfigActiveStatus(); + const { + inactiveJails, + inactiveLoading, + refreshInactiveJails, + deactivateJail, + deleteJailLocalOverride, + validateJailConfig, + activateJail, + createJailConfigFile, + } = useJailAdmin(); const [selectedName, setSelectedName] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); - - // Inactive jails - const [inactiveJails, setInactiveJails] = useState([]); - const [inactiveLoading, setInactiveLoading] = useState(false); const [activateTarget, setActivateTarget] = useState(null); - const loadInactive = useCallback((): void => { - setInactiveLoading(true); - fetchInactiveJails() - .then((res) => { setInactiveJails(res.jails); }) - .catch(() => { /* non-critical — active-only view still works */ }) - .finally(() => { setInactiveLoading(false); }); - }, []); - - useEffect(() => { - loadInactive(); - }, [loadInactive]); - - const handleDeactivate = useCallback((name: string): void => { - deactivateJail(name) - .then(() => { + const handleDeactivate = useCallback( + async (name: string): Promise => { + try { + await deactivateJail(name); setSelectedName(null); refresh(); - loadInactive(); - }) - .catch(() => { /* non-critical — list refreshes on next load */ }); - }, [refresh, loadInactive]); + refreshInactiveJails(); + } catch { + /* non-critical — list refreshes on next load */ } + }, + [deactivateJail, refresh, refreshInactiveJails], + ); - const handleDeactivateInactive = useCallback((name: string): void => { - deleteJailLocalOverride(name) - .then(() => { + const handleDeactivateInactive = useCallback( + async (name: string): Promise => { + try { + await deleteJailLocalOverride(name); setSelectedName(null); - loadInactive(); - }) - .catch(() => { /* non-critical — list refreshes on next load */ }); - }, [loadInactive]); + refreshInactiveJails(); + } catch { + /* non-critical — list refreshes on next load */ } + }, + [deleteJailLocalOverride, refreshInactiveJails], + ); const handleActivated = useCallback((): void => { setActivateTarget(null); setSelectedName(null); refresh(); - loadInactive(); - }, [refresh, loadInactive]); + refreshInactiveJails(); + }, [refresh, refreshInactiveJails]); /** Unified list items: active jails first (from useJailConfigs), then inactive. */ const listItems = useMemo>(() => { @@ -924,6 +922,7 @@ export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element { ? (): void => { handleDeactivateInactive(selectedInactiveJail.name); } : undefined } + onValidate={() => validateJailConfig(selectedInactiveJail.name)} /> ) : null} @@ -934,15 +933,36 @@ export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element { open={activateTarget !== null} onClose={() => { setActivateTarget(null); }} onActivated={handleActivated} + onValidate={async () => { + if (!activateTarget) { + return { jail_name: "", valid: false, issues: [] }; + } + return await validateJailConfig(activateTarget.name); + }} + onActivate={async (payload) => { + if (!activateTarget) { + return { + name: "", + active: false, + message: "No jail selected.", + fail2ban_running: false, + validation_warnings: [], + }; + } + return await activateJail(activateTarget.name, payload); + }} /> { setCreateDialogOpen(false); }} + onCreateJail={async (payload) => { + await createJailConfigFile(payload); + }} onCreated={() => { setCreateDialogOpen(false); refresh(); - loadInactive(); + refreshInactiveJails(); }} />
diff --git a/frontend/src/components/config/ServerHealthSection.tsx b/frontend/src/components/config/ServerHealthSection.tsx index 65c352e..4c0ed83 100644 --- a/frontend/src/components/config/ServerHealthSection.tsx +++ b/frontend/src/components/config/ServerHealthSection.tsx @@ -34,9 +34,8 @@ import { DocumentBulletList24Regular, Filter24Regular, } from "@fluentui/react-icons"; -import { fetchFail2BanLog, fetchServiceStatus } from "../../api/config"; +import { useServerHealth } from "../../hooks/useServerHealth"; import { useConfigStyles } from "./configStyles"; -import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../types/config"; // --------------------------------------------------------------------------- // Constants @@ -176,16 +175,14 @@ export function ServerHealthSection(): React.JSX.Element { const styles = useStyles(); // ---- data state ---------------------------------------------------------- - const [status, setStatus] = useState(null); - const [logData, setLogData] = useState(null); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); // ---- toolbar state ------------------------------------------------------- const [linesCount, setLinesCount] = useState(200); const [filterRaw, setFilterRaw] = useState(""); const [filterValue, setFilterValue] = useState(""); + const { status, logData, error, refresh } = useServerHealth(linesCount, filterValue); const [autoRefresh, setAutoRefresh] = useState(false); const [refreshInterval, setRefreshInterval] = useState(10); @@ -204,33 +201,20 @@ export function ServerHealthSection(): React.JSX.Element { // ---- fetch logic --------------------------------------------------------- const fetchData = useCallback( async (showSpinner: boolean): Promise => { - if (showSpinner) setIsRefreshing(true); + if (showSpinner) { + setIsRefreshing(true); + } + try { - // Use allSettled so a log-read failure doesn't hide the service status. - const [svcResult, logResult] = await Promise.allSettled([ - fetchServiceStatus(), - fetchFail2BanLog(linesCount, filterValue || undefined), - ]); - - if (svcResult.status === "fulfilled") { - setStatus(svcResult.value); - } else { - setStatus(null); - } - - if (logResult.status === "fulfilled") { - setLogData(logResult.value); - setError(null); - } else { - const reason: unknown = logResult.reason; - setError(reason instanceof Error ? reason.message : "Failed to load log data."); - } + await refresh(); } finally { - if (showSpinner) setIsRefreshing(false); + if (showSpinner) { + setIsRefreshing(false); + } setLoading(false); } }, - [linesCount, filterValue], + [refresh], ); // ---- initial load -------------------------------------------------------- diff --git a/frontend/src/components/config/__tests__/ActivateJailDialog.test.tsx b/frontend/src/components/config/__tests__/ActivateJailDialog.test.tsx index 743bbfc..7f47f49 100644 --- a/frontend/src/components/config/__tests__/ActivateJailDialog.test.tsx +++ b/frontend/src/components/config/__tests__/ActivateJailDialog.test.tsx @@ -13,7 +13,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { ActivateJailDialog } from "../ActivateJailDialog"; -import type { InactiveJail, JailActivationResponse, JailValidationResult } from "../../../types/config"; +import type { ActivateJailRequest, InactiveJail, JailActivationResponse, JailValidationResult } from "../../../types/config"; // --------------------------------------------------------------------------- // Mocks @@ -98,6 +98,15 @@ interface DialogProps { open?: boolean; onClose?: () => void; onActivated?: () => void; + onValidate?: () => Promise; + onActivate?: (payload: ActivateJailRequest) => Promise<{ + name: string; + active: boolean; + message: string; + fail2ban_running: boolean; + validation_warnings: string[]; + recovered?: boolean; + }>; } function renderDialog({ @@ -105,6 +114,18 @@ function renderDialog({ open = true, onClose = vi.fn(), onActivated = vi.fn(), + onValidate = async () => ({ + jail_name: jail?.name ?? "", + valid: true, + issues: [], + }), + onActivate = async () => ({ + name: jail?.name ?? "", + active: true, + message: "activated", + fail2ban_running: true, + validation_warnings: [], + }), }: DialogProps = {}) { return render( @@ -113,6 +134,8 @@ function renderDialog({ open={open} onClose={onClose} onActivated={onActivated} + onValidate={onValidate} + onActivate={onActivate} /> , ); diff --git a/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx b/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx index 1e2d546..358eef1 100644 --- a/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx +++ b/frontend/src/components/config/__tests__/AssignFilterDialog.test.tsx @@ -18,6 +18,11 @@ vi.mock("../../../api/config", () => ({ vi.mock("../../../api/jails", () => ({ fetchJails: vi.fn(), + startJail: vi.fn(), + stopJail: vi.fn(), + setJailIdle: vi.fn(), + reloadJail: vi.fn(), + reloadAllJails: vi.fn(), })); import { assignFilterToJail } from "../../../api/config"; @@ -68,6 +73,7 @@ function renderDialog(overrides: Partial undefined), ...overrides, }; return render( diff --git a/frontend/src/components/config/__tests__/CreateFilterDialog.test.tsx b/frontend/src/components/config/__tests__/CreateFilterDialog.test.tsx index a810e02..95a257e 100644 --- a/frontend/src/components/config/__tests__/CreateFilterDialog.test.tsx +++ b/frontend/src/components/config/__tests__/CreateFilterDialog.test.tsx @@ -52,6 +52,7 @@ function renderDialog( const props = { open: true, onClose: vi.fn(), + onCreateFilter: vi.fn(async () => createdFilter), onCreate: vi.fn(), ...overrides, }; diff --git a/frontend/src/hooks/useActionList.ts b/frontend/src/hooks/useActionList.ts new file mode 100644 index 0000000..5704da2 --- /dev/null +++ b/frontend/src/hooks/useActionList.ts @@ -0,0 +1,91 @@ +/** + * React hook for loading action metadata used by the actions tab. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchActions, removeActionFromJail, createAction, assignActionToJail } from "../api/config"; +import { handleFetchError } from "../utils/fetchError"; +import type { ActionConfig, ActionCreateRequest } from "../types/config"; + +export interface UseActionListResult { + actions: ActionConfig[]; + loading: boolean; + error: string | null; + refresh: () => void; + removeActionFromJail: (jailName: string, actionName: string) => Promise; + createAction: (payload: ActionCreateRequest) => Promise; + assignActionToJail: (jailName: string, payload: { action_name: string }, reload: boolean) => Promise; +} + +/** + * Load the action inventory and expose related action operations. + */ +export function useActionList(): UseActionListResult { + const [actions, setActions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const refresh = useCallback((): void => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + setError(null); + + fetchActions() + .then((resp) => { + if (!controller.signal.aborted) { + setActions(resp.actions); + } + }) + .catch((err: unknown) => { + if (!controller.signal.aborted) { + handleFetchError(err, setError, "Failed to load actions"); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false); + } + }); + }, []); + + useEffect(() => { + refresh(); + return (): void => { + abortRef.current?.abort(); + }; + }, [refresh]); + + const handleRemoveActionFromJail = useCallback( + async (jailName: string, actionName: string): Promise => { + await removeActionFromJail(jailName, actionName); + }, + [], + ); + + const handleCreateAction = useCallback( + async (payload: ActionCreateRequest): Promise => { + return await createAction(payload); + }, + [], + ); + + const handleAssignActionToJail = useCallback( + async (jailName: string, payload: { action_name: string }, reload: boolean): Promise => { + await assignActionToJail(jailName, payload, reload); + }, + [], + ); + + return { + actions, + loading, + error, + refresh, + removeActionFromJail: handleRemoveActionFromJail, + createAction: handleCreateAction, + assignActionToJail: handleAssignActionToJail, + }; +} diff --git a/frontend/src/hooks/useActionRawFile.ts b/frontend/src/hooks/useActionRawFile.ts new file mode 100644 index 0000000..3fd4f15 --- /dev/null +++ b/frontend/src/hooks/useActionRawFile.ts @@ -0,0 +1,32 @@ +/** + * React hook for loading and saving a single raw action file. + */ +import { useCallback } from "react"; +import { fetchActionFile, updateActionFile } from "../api/file_config"; + +export interface UseActionRawFileResult { + fetchRawContent: () => Promise; + saveRawContent: (content: string) => Promise; +} + +/** + * Return raw config file operations for an action file. + */ +export function useActionRawFile(name: string): UseActionRawFileResult { + const fetchRawContent = useCallback(async (): Promise => { + const result = await fetchActionFile(name); + return result.content; + }, [name]); + + const saveRawContent = useCallback( + async (content: string): Promise => { + await updateActionFile(name, { content }); + }, + [name], + ); + + return { + fetchRawContent, + saveRawContent, + }; +} diff --git a/frontend/src/hooks/useFilterList.ts b/frontend/src/hooks/useFilterList.ts new file mode 100644 index 0000000..12477e3 --- /dev/null +++ b/frontend/src/hooks/useFilterList.ts @@ -0,0 +1,82 @@ +/** + * React hook for loading filter config metadata used by the filter tab. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchFilters, createFilter, assignFilterToJail } from "../api/config"; +import { handleFetchError } from "../utils/fetchError"; +import type { FilterConfig, FilterCreateRequest } from "../types/config"; + +export interface UseFilterListResult { + filters: FilterConfig[]; + loading: boolean; + error: string | null; + refresh: () => void; + createFilter: (payload: FilterCreateRequest) => Promise; + assignFilterToJail: (jailName: string, payload: { filter_name: string }, reload: boolean) => Promise; +} + +/** + * Load the filter inventory and expose refresh semantics. + */ +export function useFilterList(): UseFilterListResult { + const [filters, setFilters] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const refresh = useCallback((): void => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + setError(null); + + fetchFilters() + .then((resp) => { + if (!controller.signal.aborted) { + setFilters(resp.filters); + } + }) + .catch((err: unknown) => { + if (!controller.signal.aborted) { + handleFetchError(err, setError, "Failed to load filters"); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false); + } + }); + }, []); + + useEffect(() => { + refresh(); + return (): void => { + abortRef.current?.abort(); + }; + }, [refresh]); + + const handleCreateFilter = useCallback( + async (payload: FilterCreateRequest): Promise => { + return await createFilter(payload); + }, + [], + ); + + const handleAssignFilterToJail = useCallback( + async (jailName: string, payload: { filter_name: string }, reload: boolean): Promise => { + await assignFilterToJail(jailName, payload, reload); + }, + [], + ); + + return { + filters, + loading, + error, + refresh, + createFilter: handleCreateFilter, + assignFilterToJail: handleAssignFilterToJail, + }; +} diff --git a/frontend/src/hooks/useFilterRawFile.ts b/frontend/src/hooks/useFilterRawFile.ts new file mode 100644 index 0000000..71ed384 --- /dev/null +++ b/frontend/src/hooks/useFilterRawFile.ts @@ -0,0 +1,32 @@ +/** + * React hook for loading and saving a single raw filter file. + */ +import { useCallback } from "react"; +import { fetchFilterFile, updateFilterFile } from "../api/file_config"; + +export interface UseFilterRawFileResult { + fetchRawContent: () => Promise; + saveRawContent: (content: string) => Promise; +} + +/** + * Return raw config file operations for a filter file. + */ +export function useFilterRawFile(name: string): UseFilterRawFileResult { + const fetchRawContent = useCallback(async (): Promise => { + const result = await fetchFilterFile(name); + return result.content; + }, [name]); + + const saveRawContent = useCallback( + async (content: string): Promise => { + await updateFilterFile(name, { content }); + }, + [name], + ); + + return { + fetchRawContent, + saveRawContent, + }; +} diff --git a/frontend/src/hooks/useJailAdmin.ts b/frontend/src/hooks/useJailAdmin.ts new file mode 100644 index 0000000..e8c7b24 --- /dev/null +++ b/frontend/src/hooks/useJailAdmin.ts @@ -0,0 +1,123 @@ +/** + * React hook for managing inactive jail operations and configuration actions. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + activateJail, + deactivateJail, + deleteJailLocalOverride, + fetchInactiveJails, + validateJailConfig, + createJailConfigFile, +} from "../api/config"; +import { handleFetchError } from "../utils/fetchError"; +import type { + ActivateJailRequest, + ConfFileCreateRequest, + InactiveJail, + JailActivationResponse, + JailValidationResult, +} from "../types/config"; + +export interface UseJailAdminResult { + inactiveJails: InactiveJail[]; + inactiveLoading: boolean; + inactiveError: string | null; + refreshInactiveJails: () => void; + deactivateJail: (name: string) => Promise; + deleteJailLocalOverride: (name: string) => Promise; + validateJailConfig: (name: string) => Promise; + activateJail: (name: string, payload: ActivateJailRequest) => Promise; + createJailConfigFile: (payload: ConfFileCreateRequest) => Promise; +} + +/** + * Load inactive fail2ban jails and expose the admin actions used by the + * jail configuration tab. + */ +export function useJailAdmin(): UseJailAdminResult { + const [inactiveJails, setInactiveJails] = useState([]); + const [inactiveLoading, setInactiveLoading] = useState(false); + const [inactiveError, setInactiveError] = useState(null); + const abortRef = useRef(null); + + const refreshInactiveJails = useCallback((): void => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setInactiveLoading(true); + setInactiveError(null); + + fetchInactiveJails() + .then((resp) => { + if (!ctrl.signal.aborted) { + setInactiveJails(resp.jails); + } + }) + .catch((err: unknown) => { + if (!ctrl.signal.aborted) { + handleFetchError(err, setInactiveError, "Failed to load inactive jails"); + } + }) + .finally(() => { + if (!ctrl.signal.aborted) { + setInactiveLoading(false); + } + }); + }, []); + + useEffect(() => { + refreshInactiveJails(); + return (): void => { + abortRef.current?.abort(); + }; + }, [refreshInactiveJails]); + + const handleDeactivateJail = useCallback( + async (name: string): Promise => { + await deactivateJail(name); + }, + [], + ); + + const handleDeleteLocalOverride = useCallback( + async (name: string): Promise => { + await deleteJailLocalOverride(name); + }, + [], + ); + + const handleValidateJailConfig = useCallback( + async (name: string): Promise => { + return await validateJailConfig(name); + }, + [], + ); + + const handleActivateJail = useCallback( + async (name: string, payload: ActivateJailRequest): Promise => { + return await activateJail(name, payload); + }, + [], + ); + + const handleCreateJailConfigFile = useCallback( + async (payload: ConfFileCreateRequest): Promise => { + await createJailConfigFile(payload); + }, + [], + ); + + return { + inactiveJails, + inactiveLoading, + inactiveError, + refreshInactiveJails, + deactivateJail: handleDeactivateJail, + deleteJailLocalOverride: handleDeleteLocalOverride, + validateJailConfig: handleValidateJailConfig, + activateJail: handleActivateJail, + createJailConfigFile: handleCreateJailConfigFile, + }; +} diff --git a/frontend/src/hooks/useJailConfigOperations.ts b/frontend/src/hooks/useJailConfigOperations.ts new file mode 100644 index 0000000..5f62743 --- /dev/null +++ b/frontend/src/hooks/useJailConfigOperations.ts @@ -0,0 +1,57 @@ +/** + * React hook for performing jail-specific configuration operations. + */ +import { useCallback } from "react"; +import { + addLogPath, + deleteLogPath, + fetchJailConfigFileContent, + updateJailConfigFile, +} from "../api/config"; +import type { AddLogPathRequest } from "../types/config"; + +export interface UseJailConfigOperationsResult { + addLogPath: (payload: AddLogPathRequest) => Promise; + deleteLogPath: (path: string) => Promise; + fetchRawContent: () => Promise; + saveRawContent: (content: string) => Promise; +} + +/** + * Create callbacks for jail-specific config operations that are used by + * jail config detail components. + */ +export function useJailConfigOperations(jailName: string): UseJailConfigOperationsResult { + const addLog = useCallback( + async (payload: AddLogPathRequest): Promise => { + await addLogPath(jailName, payload); + }, + [jailName], + ); + + const deletePath = useCallback( + async (path: string): Promise => { + await deleteLogPath(jailName, path); + }, + [jailName], + ); + + const fetchRawContent = useCallback(async (): Promise => { + const result = await fetchJailConfigFileContent(`${jailName}.conf`); + return result.content; + }, [jailName]); + + const saveRawContent = useCallback( + async (content: string): Promise => { + await updateJailConfigFile(`${jailName}.conf`, { content }); + }, + [jailName], + ); + + return { + addLogPath: addLog, + deleteLogPath: deletePath, + fetchRawContent, + saveRawContent, + }; +} diff --git a/frontend/src/hooks/useServerHealth.ts b/frontend/src/hooks/useServerHealth.ts new file mode 100644 index 0000000..eec9b0c --- /dev/null +++ b/frontend/src/hooks/useServerHealth.ts @@ -0,0 +1,53 @@ +/** + * React hook for service health and log viewer data fetching. + */ +import { useCallback, useState } from "react"; +import { fetchFail2BanLog, fetchServiceStatus } from "../api/config"; +import type { Fail2BanLogResponse, ServiceStatusResponse } from "../types/config"; + +export interface UseServerHealthResult { + status: ServiceStatusResponse | null; + logData: Fail2BanLogResponse | null; + error: string | null; + refresh: () => Promise; +} + +/** + * Load service status and fail2ban log data for the server health panel. + */ +export function useServerHealth( + linesCount: number, + filterValue: string, +): UseServerHealthResult { + const [status, setStatus] = useState(null); + const [logData, setLogData] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async (): Promise => { + try { + const [svcResult, logResult] = await Promise.allSettled([ + fetchServiceStatus(), + fetchFail2BanLog(linesCount, filterValue || undefined), + ]); + + if (svcResult.status === "fulfilled") { + setStatus(svcResult.value); + } else { + setStatus(null); + } + + if (logResult.status === "fulfilled") { + setLogData(logResult.value); + setError(null); + } else { + const reason: unknown = logResult.reason; + setError(reason instanceof Error ? reason.message : "Failed to load log data."); + } + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err); + setError(reason); + } + }, [filterValue, linesCount]); + + return { status, logData, error, refresh }; +}