Task 11: Remove direct API calls from components

This commit is contained in:
2026-04-18 21:20:45 +02:00
parent 3f197b1ad7
commit 2105f8b435
21 changed files with 712 additions and 266 deletions

View File

@@ -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"). **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. **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.
--- ---

View File

@@ -10,7 +10,7 @@
* creating a new ``action.d/*.local`` file. * creating a new ``action.d/*.local`` file.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
Button, Button,
Field, Field,
@@ -22,13 +22,10 @@ import {
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { Add24Regular, Delete24Regular, LinkEdit24Regular } from "@fluentui/react-icons"; import { Add24Regular, Delete24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
import { import type { ActionConfig } from "../../types/config";
fetchActionFile,
updateActionFile,
} from "../../api/file_config";
import { fetchActions, removeActionFromJail } from "../../api/config";
import type { ActionConfig, ConfFileUpdateRequest } from "../../types/config";
import { ActionForm } from "./ActionForm"; import { ActionForm } from "./ActionForm";
import { useActionList } from "../../hooks/useActionList";
import { useActionRawFile } from "../../hooks/useActionRawFile";
import { AssignActionDialog } from "./AssignActionDialog"; import { AssignActionDialog } from "./AssignActionDialog";
import { ConfigListDetail } from "./ConfigListDetail"; import { ConfigListDetail } from "./ConfigListDetail";
import { CreateActionDialog } from "./CreateActionDialog"; import { CreateActionDialog } from "./CreateActionDialog";
@@ -55,7 +52,7 @@ function actionBadgeLabel(a: ActionConfig): string {
interface ActionDetailProps { interface ActionDetailProps {
action: ActionConfig; action: ActionConfig;
onAssignClick: () => void; onAssignClick: () => void;
onRemovedFromJail: () => void; onRemovedFromJail: (jailName: string) => Promise<void>;
} }
/** /**
@@ -73,27 +70,15 @@ function ActionDetail({
const styles = useConfigStyles(); const styles = useConfigStyles();
const [removingJail, setRemovingJail] = useState<string | null>(null); const [removingJail, setRemovingJail] = useState<string | null>(null);
const [removeError, setRemoveError] = useState<string | null>(null); const [removeError, setRemoveError] = useState<string | null>(null);
const { fetchRawContent, saveRawContent } = useActionRawFile(action.name);
const fetchRaw = useCallback(async (): Promise<string> => {
const result = await fetchActionFile(action.name);
return result.content;
}, [action.name]);
const saveRaw = useCallback(
async (content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content };
await updateActionFile(action.name, req);
},
[action.name],
);
const handleRemoveFromJail = useCallback( const handleRemoveFromJail = useCallback(
(jailName: string): void => { (jailName: string): void => {
setRemovingJail(jailName); setRemovingJail(jailName);
setRemoveError(null); setRemoveError(null);
removeActionFromJail(jailName, action.name) onRemovedFromJail(jailName)
.then(() => { .then(() => {
onRemovedFromJail(); // No-op: parent refreshes the list.
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
setRemoveError( setRemoveError(
@@ -104,7 +89,7 @@ function ActionDetail({
setRemovingJail(null); setRemovingJail(null);
}); });
}, },
[action.name, onRemovedFromJail], [onRemovedFromJail],
); );
return ( return (
@@ -174,8 +159,8 @@ function ActionDetail({
{/* Raw config */} {/* Raw config */}
<div style={{ marginTop: tokens.spacingVerticalL }}> <div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection <RawConfigSection
fetchContent={fetchRaw} fetchContent={fetchRawContent}
saveContent={saveRaw} saveContent={saveRawContent}
label="Raw Action Configuration" label="Raw Action Configuration"
/> />
</div> </div>
@@ -193,42 +178,18 @@ function ActionDetail({
* @returns JSX element. * @returns JSX element.
*/ */
export function ActionsTab(): React.JSX.Element { export function ActionsTab(): React.JSX.Element {
const [actions, setActions] = useState<ActionConfig[]>([]); const {
const [loading, setLoading] = useState(true); actions,
const [error, setError] = useState<string | null>(null); loading,
error,
refresh,
removeActionFromJail,
createAction,
assignActionToJail,
} = useActionList();
const [selectedName, setSelectedName] = useState<string | null>(null); const [selectedName, setSelectedName] = useState<string | null>(null);
const [assignOpen, setAssignOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const abortRef = useRef<AbortController | null>(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. */ /** The full ActionConfig for the currently selected name. */
const selectedAction = useMemo( const selectedAction = useMemo(
@@ -238,22 +199,26 @@ export function ActionsTab(): React.JSX.Element {
const handleAssigned = useCallback((): void => { const handleAssigned = useCallback((): void => {
setAssignOpen(false); setAssignOpen(false);
// Refresh action list so active status is up-to-date. refresh();
loadActions(); }, [refresh]);
}, [loadActions]);
const handleCreated = useCallback( const handleCreated = useCallback(
(newAction: ActionConfig): void => { (newAction: ActionConfig): void => {
setCreateOpen(false); setCreateOpen(false);
setActions((prev) => [...prev, newAction]);
setSelectedName(newAction.name); setSelectedName(newAction.name);
refresh();
}, },
[], [refresh],
); );
const handleRemovedFromJail = useCallback((): void => { const handleRemovedFromJail = useCallback(
loadActions(); async (jailName: string): Promise<void> => {
}, [loadActions]); if (!selectedName) return;
await removeActionFromJail(jailName, selectedName);
refresh();
},
[removeActionFromJail, refresh, selectedName],
);
if (loading) { if (loading) {
return ( return (
@@ -292,8 +257,8 @@ export function ActionsTab(): React.JSX.Element {
itemBadgeLabel={actionBadgeLabel} itemBadgeLabel={actionBadgeLabel}
selectedName={selectedName} selectedName={selectedName}
onSelect={setSelectedName} onSelect={setSelectedName}
loading={false} loading={loading}
error={null} error={error}
listHeader={listHeader} listHeader={listHeader}
> >
{selectedAction !== null && ( {selectedAction !== null && (
@@ -311,11 +276,15 @@ export function ActionsTab(): React.JSX.Element {
open={assignOpen} open={assignOpen}
onClose={() => { setAssignOpen(false); }} onClose={() => { setAssignOpen(false); }}
onAssigned={handleAssigned} onAssigned={handleAssigned}
onAssign={async (jailName, payload, reload) => {
await assignActionToJail(jailName, payload, reload);
}}
/> />
<CreateActionDialog <CreateActionDialog
open={createOpen} open={createOpen}
onClose={() => { setCreateOpen(false); }} onClose={() => { setCreateOpen(false); }}
onCreateAction={createAction}
onCreate={handleCreated} onCreate={handleCreated}
/> />
</> </>

View File

@@ -27,11 +27,12 @@ import {
Text, Text,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { activateJail, validateJailConfig } from "../../api/config";
import type { import type {
ActivateJailRequest, ActivateJailRequest,
JailActivationResponse,
InactiveJail, InactiveJail,
JailValidationIssue, JailValidationIssue,
JailValidationResult,
} from "../../types/config"; } from "../../types/config";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
@@ -48,6 +49,10 @@ export interface ActivateJailDialogProps {
onClose: () => void; onClose: () => void;
/** Called after the jail has been successfully activated. */ /** Called after the jail has been successfully activated. */
onActivated: () => void; onActivated: () => void;
/** Validates the inactive jail configuration before activation. */
onValidate: () => Promise<JailValidationResult>;
/** Activates the jail with optional override fields. */
onActivate: (payload: ActivateJailRequest) => Promise<JailActivationResponse>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -68,6 +73,8 @@ export function ActivateJailDialog({
open, open,
onClose, onClose,
onActivated, onActivated,
onValidate,
onActivate,
}: ActivateJailDialogProps): React.JSX.Element { }: ActivateJailDialogProps): React.JSX.Element {
const [bantime, setBantime] = useState(""); const [bantime, setBantime] = useState("");
const [findtime, setFindtime] = useState(""); const [findtime, setFindtime] = useState("");
@@ -103,7 +110,7 @@ export function ActivateJailDialog({
setValidationIssues([]); setValidationIssues([]);
setValidationWarnings([]); setValidationWarnings([]);
validateJailConfig(jail.name) onValidate()
.then((result) => { .then((result) => {
setValidationIssues(result.issues); setValidationIssues(result.issues);
}) })
@@ -114,7 +121,7 @@ export function ActivateJailDialog({
.finally(() => { .finally(() => {
setValidating(false); setValidating(false);
}); });
}, [open, jail]); }, [open, jail, onValidate]);
const handleClose = (): void => { const handleClose = (): void => {
if (submitting) return; if (submitting) return;
@@ -143,7 +150,7 @@ export function ActivateJailDialog({
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
activateJail(jail.name, overrides) onActivate(overrides)
.then((result) => { .then((result) => {
if (!result.active) { if (!result.active) {
if (result.recovered === true) { if (result.recovered === true) {

View File

@@ -23,10 +23,8 @@ import {
Text, Text,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { assignActionToJail } from "../../api/config"; import { useJails } from "../../hooks/useJails";
import { fetchJails } from "../../api/jails";
import type { AssignActionRequest } from "../../types/config"; import type { AssignActionRequest } from "../../types/config";
import type { JailSummary } from "../../types/jail";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,6 +43,8 @@ export interface AssignActionDialogProps {
onClose: () => void; onClose: () => void;
/** Called after the action has been successfully assigned. */ /** Called after the action has been successfully assigned. */
onAssigned: () => void; onAssigned: () => void;
/** Assigns the action to a running jail. */
onAssign: (jailName: string, payload: AssignActionRequest, reload: boolean) => Promise<void>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -66,35 +66,29 @@ export function AssignActionDialog({
open, open,
onClose, onClose,
onAssigned, onAssigned,
onAssign,
}: AssignActionDialogProps): React.JSX.Element { }: AssignActionDialogProps): React.JSX.Element {
const [jails, setJails] = useState<JailSummary[]>([]); const { jails, loading: jailsLoading, error: jailsError } = useJails();
const [jailsLoading, setJailsLoading] = useState(false);
const [selectedJail, setSelectedJail] = useState(""); const [selectedJail, setSelectedJail] = useState("");
const [reload, setReload] = useState(false); const [reload, setReload] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Fetch running jails whenever the dialog opens. const activeJails = jails.filter((j) => j.enabled);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setJailsLoading(true);
setError(null); setError(null);
setSelectedJail(""); setSelectedJail("");
setReload(false); 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]); }, [open]);
useEffect(() => {
if (jailsError && open) {
setError(jailsError);
}
}, [jailsError, open]);
const handleClose = useCallback((): void => { const handleClose = useCallback((): void => {
if (submitting) return; if (submitting) return;
setError(null); setError(null);
@@ -108,7 +102,7 @@ export function AssignActionDialog({
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
assignActionToJail(selectedJail, req, reload) onAssign(selectedJail, req, reload)
.then(() => { .then(() => {
onAssigned(); onAssigned();
}) })
@@ -120,7 +114,7 @@ export function AssignActionDialog({
.finally(() => { .finally(() => {
setSubmitting(false); setSubmitting(false);
}); });
}, [actionName, selectedJail, reload, submitting, onAssigned]); }, [actionName, selectedJail, reload, submitting, onAssigned, onAssign]);
const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; const canConfirm = selectedJail !== "" && !submitting && !jailsLoading;
@@ -170,7 +164,7 @@ export function AssignActionDialog({
<option value="" disabled> <option value="" disabled>
select a jail select a jail
</option> </option>
{jails.map((j) => ( {activeJails.map((j) => (
<option key={j.name} value={j.name}> <option key={j.name} value={j.name}>
{j.name} {j.name}
</option> </option>

View File

@@ -23,10 +23,8 @@ import {
Text, Text,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { assignFilterToJail } from "../../api/config"; import { useJails } from "../../hooks/useJails";
import { fetchJails } from "../../api/jails";
import type { AssignFilterRequest } from "../../types/config"; import type { AssignFilterRequest } from "../../types/config";
import type { JailSummary } from "../../types/jail";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,6 +43,8 @@ export interface AssignFilterDialogProps {
onClose: () => void; onClose: () => void;
/** Called after the filter has been successfully assigned. */ /** Called after the filter has been successfully assigned. */
onAssigned: () => void; onAssigned: () => void;
/** Assigns the filter to a running jail. */
onAssign: (jailName: string, payload: AssignFilterRequest, reload: boolean) => Promise<void>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -66,35 +66,29 @@ export function AssignFilterDialog({
open, open,
onClose, onClose,
onAssigned, onAssigned,
onAssign,
}: AssignFilterDialogProps): React.JSX.Element { }: AssignFilterDialogProps): React.JSX.Element {
const [jails, setJails] = useState<JailSummary[]>([]); const { jails, loading: jailsLoading, error: jailsError } = useJails();
const [jailsLoading, setJailsLoading] = useState(false);
const [selectedJail, setSelectedJail] = useState(""); const [selectedJail, setSelectedJail] = useState("");
const [reload, setReload] = useState(false); const [reload, setReload] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Fetch running jails whenever the dialog opens. const activeJails = jails.filter((j) => j.enabled);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setJailsLoading(true);
setError(null); setError(null);
setSelectedJail(""); setSelectedJail("");
setReload(false); 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]); }, [open]);
useEffect(() => {
if (jailsError && open) {
setError(jailsError);
}
}, [jailsError, open]);
const handleClose = useCallback((): void => { const handleClose = useCallback((): void => {
if (submitting) return; if (submitting) return;
setError(null); setError(null);
@@ -108,7 +102,7 @@ export function AssignFilterDialog({
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
assignFilterToJail(selectedJail, req, reload) onAssign(selectedJail, req, reload)
.then(() => { .then(() => {
onAssigned(); onAssigned();
}) })
@@ -120,7 +114,7 @@ export function AssignFilterDialog({
.finally(() => { .finally(() => {
setSubmitting(false); setSubmitting(false);
}); });
}, [filterName, selectedJail, reload, submitting, onAssigned]); }, [filterName, selectedJail, reload, submitting, onAssigned, onAssign]);
const canConfirm = selectedJail !== "" && !submitting && !jailsLoading; const canConfirm = selectedJail !== "" && !submitting && !jailsLoading;
@@ -171,7 +165,7 @@ export function AssignFilterDialog({
<option value="" disabled> <option value="" disabled>
select a jail select a jail
</option> </option>
{jails.map((j) => ( {activeJails.map((j) => (
<option key={j.name} value={j.name}> <option key={j.name} value={j.name}>
{j.name} {j.name}
</option> </option>

View File

@@ -23,7 +23,6 @@ import {
Textarea, Textarea,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { createAction } from "../../api/config";
import type { ActionConfig, ActionCreateRequest } from "../../types/config"; import type { ActionConfig, ActionCreateRequest } from "../../types/config";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
@@ -36,6 +35,12 @@ export interface CreateActionDialogProps {
open: boolean; open: boolean;
/** Called when the dialog should close without taking action. */ /** Called when the dialog should close without taking action. */
onClose: () => void; onClose: () => void;
/**
* Called when the form is submitted with valid dialog data.
*
* @param payload - Create request payload.
*/
onCreateAction: (payload: ActionCreateRequest) => Promise<ActionConfig>;
/** /**
* Called after the action has been successfully created. * Called after the action has been successfully created.
* *
@@ -60,6 +65,7 @@ export interface CreateActionDialogProps {
export function CreateActionDialog({ export function CreateActionDialog({
open, open,
onClose, onClose,
onCreateAction,
onCreate, onCreate,
}: CreateActionDialogProps): React.JSX.Element { }: CreateActionDialogProps): React.JSX.Element {
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -96,7 +102,7 @@ export function CreateActionDialog({
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
createAction(req) onCreateAction(req)
.then((action) => { .then((action) => {
onCreate(action); onCreate(action);
}) })

View File

@@ -23,7 +23,6 @@ import {
Textarea, Textarea,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { createFilter } from "../../api/config";
import type { FilterConfig, FilterCreateRequest } from "../../types/config"; import type { FilterConfig, FilterCreateRequest } from "../../types/config";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
@@ -36,6 +35,8 @@ export interface CreateFilterDialogProps {
open: boolean; open: boolean;
/** Called when the dialog should close without taking action. */ /** Called when the dialog should close without taking action. */
onClose: () => void; onClose: () => void;
/** Called when the form is submitted with valid dialog data. */
onCreateFilter: (payload: FilterCreateRequest) => Promise<FilterConfig>;
/** /**
* Called after the filter has been successfully created. * Called after the filter has been successfully created.
* *
@@ -73,6 +74,7 @@ function splitLines(value: string): string[] {
export function CreateFilterDialog({ export function CreateFilterDialog({
open, open,
onClose, onClose,
onCreateFilter,
onCreate, onCreate,
}: CreateFilterDialogProps): React.JSX.Element { }: CreateFilterDialogProps): React.JSX.Element {
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -109,7 +111,7 @@ export function CreateFilterDialog({
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
createFilter(req) onCreateFilter(req)
.then((filter) => { .then((filter) => {
onCreate(filter); onCreate(filter);
}) })

View File

@@ -22,7 +22,6 @@ import {
Text, Text,
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { createJailConfigFile } from "../../api/file_config";
import type { ConfFileCreateRequest } from "../../types/config"; import type { ConfFileCreateRequest } from "../../types/config";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
@@ -35,6 +34,8 @@ export interface CreateJailDialogProps {
open: boolean; open: boolean;
/** Called when the dialog should close without taking action. */ /** Called when the dialog should close without taking action. */
onClose: () => void; onClose: () => void;
/** Called when the form is submitted with valid dialog data. */
onCreateJail: (payload: ConfFileCreateRequest) => Promise<void>;
/** Called after the jail config file has been successfully created. */ /** Called after the jail config file has been successfully created. */
onCreated: () => void; onCreated: () => void;
} }
@@ -56,6 +57,7 @@ export interface CreateJailDialogProps {
export function CreateJailDialog({ export function CreateJailDialog({
open, open,
onClose, onClose,
onCreateJail,
onCreated, onCreated,
}: CreateJailDialogProps): React.JSX.Element { }: CreateJailDialogProps): React.JSX.Element {
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -87,7 +89,7 @@ export function CreateJailDialog({
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
createJailConfigFile(req) onCreateJail(req)
.then(() => { .then(() => {
onCreated(); onCreated();
}) })

View File

@@ -10,7 +10,7 @@
* creating a new ``filter.d/*.local`` file. * creating a new ``filter.d/*.local`` file.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
Button, Button,
Field, Field,
@@ -22,14 +22,14 @@ import {
tokens, tokens,
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { Add24Regular, LinkEdit24Regular } from "@fluentui/react-icons"; import { Add24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
import { fetchFilterFile, updateFilterFile } from "../../api/file_config"; import type { FilterConfig } from "../../types/config";
import { fetchFilters } from "../../api/config";
import type { ConfFileUpdateRequest, FilterConfig } from "../../types/config";
import { AssignFilterDialog } from "./AssignFilterDialog"; import { AssignFilterDialog } from "./AssignFilterDialog";
import { ConfigListDetail } from "./ConfigListDetail"; import { ConfigListDetail } from "./ConfigListDetail";
import { CreateFilterDialog } from "./CreateFilterDialog"; import { CreateFilterDialog } from "./CreateFilterDialog";
import { FilterForm } from "./FilterForm"; import { FilterForm } from "./FilterForm";
import { RawConfigSection } from "./RawConfigSection"; import { RawConfigSection } from "./RawConfigSection";
import { useFilterList } from "../../hooks/useFilterList";
import { useFilterRawFile } from "../../hooks/useFilterRawFile";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -69,19 +69,7 @@ function FilterDetail({
onAssignClick, onAssignClick,
}: FilterDetailProps): React.JSX.Element { }: FilterDetailProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const { fetchRawContent, saveRawContent } = useFilterRawFile(filter.name);
const fetchRaw = useCallback(async (): Promise<string> => {
const result = await fetchFilterFile(filter.name);
return result.content;
}, [filter.name]);
const saveRaw = useCallback(
async (content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content };
await updateFilterFile(filter.name, req);
},
[filter.name],
);
return ( return (
<div> <div>
@@ -114,8 +102,8 @@ function FilterDetail({
{/* Raw config */} {/* Raw config */}
<div style={{ marginTop: tokens.spacingVerticalL }}> <div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection <RawConfigSection
fetchContent={fetchRaw} fetchContent={fetchRawContent}
saveContent={saveRaw} saveContent={saveRawContent}
label="Raw Filter Configuration" label="Raw Filter Configuration"
/> />
</div> </div>
@@ -133,42 +121,17 @@ function FilterDetail({
* @returns JSX element. * @returns JSX element.
*/ */
export function FiltersTab(): React.JSX.Element { export function FiltersTab(): React.JSX.Element {
const [filters, setFilters] = useState<FilterConfig[]>([]); const {
const [loading, setLoading] = useState(true); filters,
const [error, setError] = useState<string | null>(null); loading,
error,
refresh,
createFilter,
assignFilterToJail,
} = useFilterList();
const [selectedName, setSelectedName] = useState<string | null>(null); const [selectedName, setSelectedName] = useState<string | null>(null);
const [assignOpen, setAssignOpen] = useState(false); const [assignOpen, setAssignOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const abortRef = useRef<AbortController | null>(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. */ /** The full FilterConfig for the currently selected name. */
const selectedFilter = useMemo( const selectedFilter = useMemo(
@@ -178,17 +141,16 @@ export function FiltersTab(): React.JSX.Element {
const handleAssigned = useCallback((): void => { const handleAssigned = useCallback((): void => {
setAssignOpen(false); setAssignOpen(false);
// Refresh filter list so active status is up-to-date. refresh();
loadFilters(); }, [refresh]);
}, [loadFilters]);
const handleCreated = useCallback( const handleCreated = useCallback(
(newFilter: FilterConfig): void => { (newFilter: FilterConfig): void => {
setCreateOpen(false); setCreateOpen(false);
setFilters((prev) => [...prev, newFilter]);
setSelectedName(newFilter.name); setSelectedName(newFilter.name);
refresh();
}, },
[], [refresh],
); );
if (loading) { if (loading) {
@@ -228,8 +190,8 @@ export function FiltersTab(): React.JSX.Element {
itemBadgeLabel={filterBadgeLabel} itemBadgeLabel={filterBadgeLabel}
selectedName={selectedName} selectedName={selectedName}
onSelect={setSelectedName} onSelect={setSelectedName}
loading={false} loading={loading}
error={null} error={error}
listHeader={listHeader} listHeader={listHeader}
> >
{selectedFilter !== null && ( {selectedFilter !== null && (
@@ -246,11 +208,15 @@ export function FiltersTab(): React.JSX.Element {
open={assignOpen} open={assignOpen}
onClose={() => { setAssignOpen(false); }} onClose={() => { setAssignOpen(false); }}
onAssigned={handleAssigned} onAssigned={handleAssigned}
onAssign={async (jailName, payload, reload) => {
await assignFilterToJail(jailName, payload, reload);
}}
/> />
<CreateFilterDialog <CreateFilterDialog
open={createOpen} open={createOpen}
onClose={() => { setCreateOpen(false); }} onClose={() => { setCreateOpen(false); }}
onCreateFilter={createFilter}
onCreate={handleCreated} onCreate={handleCreated}
/> />
</> </>

View File

@@ -32,19 +32,8 @@ import {
Play24Regular, Play24Regular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import {
addLogPath,
deactivateJail,
deleteJailLocalOverride,
deleteLogPath,
fetchInactiveJails,
fetchJailConfigFileContent,
updateJailConfigFile,
validateJailConfig,
} from "../../api/config";
import type { import type {
AddLogPathRequest, AddLogPathRequest,
ConfFileUpdateRequest,
InactiveJail, InactiveJail,
JailConfig, JailConfig,
JailConfigUpdate, JailConfigUpdate,
@@ -54,6 +43,8 @@ import type {
import { useAutoSave } from "../../hooks/useAutoSave"; import { useAutoSave } from "../../hooks/useAutoSave";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus"; import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
import { useJailConfigs } from "../../hooks/useConfig"; import { useJailConfigs } from "../../hooks/useConfig";
import { useJailAdmin } from "../../hooks/useJailAdmin";
import { useJailConfigOperations } from "../../hooks/useJailConfigOperations";
import { ActivateJailDialog } from "./ActivateJailDialog"; import { ActivateJailDialog } from "./ActivateJailDialog";
import { AutoSaveIndicator } from "./AutoSaveIndicator"; import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ConfigListDetail } from "./ConfigListDetail"; import { ConfigListDetail } from "./ConfigListDetail";
@@ -166,12 +157,19 @@ function JailConfigDetail({
esc0?.overall_jails ?? false, esc0?.overall_jails ?? false,
); );
const {
addLogPath: addLogPathToJail,
deleteLogPath: deleteJailLogPath,
fetchRawContent,
saveRawContent,
} = useJailConfigOperations(jail.name);
const handleDeleteLogPath = useCallback( const handleDeleteLogPath = useCallback(
async (path: string) => { async (path: string) => {
setDeletingPath(path); setDeletingPath(path);
setMsg(null); setMsg(null);
try { try {
await deleteLogPath(jail.name, path); await deleteJailLogPath(path);
setLogPaths((prev) => prev.filter((p) => p !== path)); setLogPaths((prev) => prev.filter((p) => p !== path));
setMsg({ text: `Removed log path: ${path}`, ok: true }); setMsg({ text: `Removed log path: ${path}`, ok: true });
} catch (err: unknown) { } catch (err: unknown) {
@@ -183,7 +181,7 @@ function JailConfigDetail({
setDeletingPath(null); setDeletingPath(null);
} }
}, },
[jail.name], [deleteJailLogPath],
); );
const handleAddLogPath = useCallback(async () => { const handleAddLogPath = useCallback(async () => {
@@ -193,7 +191,7 @@ function JailConfigDetail({
setMsg(null); setMsg(null);
try { try {
const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail }; const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail };
await addLogPath(jail.name, req); await addLogPathToJail(req);
setLogPaths((prev) => [...prev, trimmed]); setLogPaths((prev) => [...prev, trimmed]);
setNewLogPath(""); setNewLogPath("");
setMsg({ text: `Added log path: ${trimmed}`, ok: true }); setMsg({ text: `Added log path: ${trimmed}`, ok: true });
@@ -205,7 +203,7 @@ function JailConfigDetail({
} finally { } finally {
setAddingLogPath(false); setAddingLogPath(false);
} }
}, [jail.name, newLogPath, newLogPathTail]); }, [addLogPathToJail, newLogPath, newLogPathTail]);
const autoSavePayload = useMemo<JailConfigUpdate>( const autoSavePayload = useMemo<JailConfigUpdate>(
() => ({ () => ({
@@ -249,16 +247,14 @@ function JailConfigDetail({
// Raw config file fetch/save helpers — uses jail.d/<name>.conf convention. // Raw config file fetch/save helpers — uses jail.d/<name>.conf convention.
const fetchRaw = useCallback(async (): Promise<string> => { const fetchRaw = useCallback(async (): Promise<string> => {
const result = await fetchJailConfigFileContent(`${jail.name}.conf`); return await fetchRawContent();
return result.content; }, [fetchRawContent]);
}, [jail.name]);
const saveRaw = useCallback( const saveRaw = useCallback(
async (content: string): Promise<void> => { async (content: string): Promise<void> => {
const req: ConfFileUpdateRequest = { content }; await saveRawContent(content);
await updateJailConfigFile(`${jail.name}.conf`, req);
}, },
[jail.name], [saveRawContent],
); );
return ( return (
@@ -629,6 +625,7 @@ interface InactiveJailDetailProps {
onActivate: () => void; onActivate: () => void;
/** Called when the user requests removal of the .local override file. */ /** Called when the user requests removal of the .local override file. */
onDeactivate?: () => void; onDeactivate?: () => void;
onValidate: () => Promise<JailValidationResult>;
} }
/** /**
@@ -646,6 +643,7 @@ function InactiveJailDetail({
jail, jail,
onActivate, onActivate,
onDeactivate, onDeactivate,
onValidate,
}: InactiveJailDetailProps): React.JSX.Element { }: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles(); const styles = useConfigStyles();
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
@@ -654,11 +652,11 @@ function InactiveJailDetail({
const handleValidate = useCallback((): void => { const handleValidate = useCallback((): void => {
setValidating(true); setValidating(true);
setValidationResult(null); setValidationResult(null);
validateJailConfig(jail.name) onValidate()
.then((result) => { setValidationResult(result); }) .then((result) => { setValidationResult(result); })
.catch(() => { /* validation call failed — ignore */ }) .catch(() => { /* validation call failed — ignore */ })
.finally(() => { setValidating(false); }); .finally(() => { setValidating(false); });
}, [jail.name]); }, [onValidate]);
const blockingIssues: JailValidationIssue[] = const blockingIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field !== "logpath") ?? []; 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 } = const { jails, loading, error, refresh, updateJail } =
useJailConfigs(); useJailConfigs();
const { activeJails } = useConfigActiveStatus(); const { activeJails } = useConfigActiveStatus();
const {
inactiveJails,
inactiveLoading,
refreshInactiveJails,
deactivateJail,
deleteJailLocalOverride,
validateJailConfig,
activateJail,
createJailConfigFile,
} = useJailAdmin();
const [selectedName, setSelectedName] = useState<string | null>(null); const [selectedName, setSelectedName] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Inactive jails
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [inactiveLoading, setInactiveLoading] = useState(false);
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null); const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
const loadInactive = useCallback((): void => { const handleDeactivate = useCallback(
setInactiveLoading(true); async (name: string): Promise<void> => {
fetchInactiveJails() try {
.then((res) => { setInactiveJails(res.jails); }) await deactivateJail(name);
.catch(() => { /* non-critical — active-only view still works */ })
.finally(() => { setInactiveLoading(false); });
}, []);
useEffect(() => {
loadInactive();
}, [loadInactive]);
const handleDeactivate = useCallback((name: string): void => {
deactivateJail(name)
.then(() => {
setSelectedName(null); setSelectedName(null);
refresh(); refresh();
loadInactive(); refreshInactiveJails();
}) } catch {
.catch(() => { /* non-critical — list refreshes on next load */ }); /* non-critical — list refreshes on next load */ }
}, [refresh, loadInactive]); },
[deactivateJail, refresh, refreshInactiveJails],
);
const handleDeactivateInactive = useCallback((name: string): void => { const handleDeactivateInactive = useCallback(
deleteJailLocalOverride(name) async (name: string): Promise<void> => {
.then(() => { try {
await deleteJailLocalOverride(name);
setSelectedName(null); setSelectedName(null);
loadInactive(); refreshInactiveJails();
}) } catch {
.catch(() => { /* non-critical — list refreshes on next load */ }); /* non-critical — list refreshes on next load */ }
}, [loadInactive]); },
[deleteJailLocalOverride, refreshInactiveJails],
);
const handleActivated = useCallback((): void => { const handleActivated = useCallback((): void => {
setActivateTarget(null); setActivateTarget(null);
setSelectedName(null); setSelectedName(null);
refresh(); refresh();
loadInactive(); refreshInactiveJails();
}, [refresh, loadInactive]); }, [refresh, refreshInactiveJails]);
/** Unified list items: active jails first (from useJailConfigs), then inactive. */ /** Unified list items: active jails first (from useJailConfigs), then inactive. */
const listItems = useMemo<Array<{ name: string; kind: "active" | "inactive" }>>(() => { const listItems = useMemo<Array<{ name: string; kind: "active" | "inactive" }>>(() => {
@@ -924,6 +922,7 @@ export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); } ? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
: undefined : undefined
} }
onValidate={() => validateJailConfig(selectedInactiveJail.name)}
/> />
) : null} ) : null}
</ConfigListDetail> </ConfigListDetail>
@@ -934,15 +933,36 @@ export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
open={activateTarget !== null} open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }} onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated} 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);
}}
/> />
<CreateJailDialog <CreateJailDialog
open={createDialogOpen} open={createDialogOpen}
onClose={() => { setCreateDialogOpen(false); }} onClose={() => { setCreateDialogOpen(false); }}
onCreateJail={async (payload) => {
await createJailConfigFile(payload);
}}
onCreated={() => { onCreated={() => {
setCreateDialogOpen(false); setCreateDialogOpen(false);
refresh(); refresh();
loadInactive(); refreshInactiveJails();
}} }}
/> />
</div> </div>

View File

@@ -34,9 +34,8 @@ import {
DocumentBulletList24Regular, DocumentBulletList24Regular,
Filter24Regular, Filter24Regular,
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { fetchFail2BanLog, fetchServiceStatus } from "../../api/config"; import { useServerHealth } from "../../hooks/useServerHealth";
import { useConfigStyles } from "./configStyles"; import { useConfigStyles } from "./configStyles";
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../types/config";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -176,16 +175,14 @@ export function ServerHealthSection(): React.JSX.Element {
const styles = useStyles(); const styles = useStyles();
// ---- data state ---------------------------------------------------------- // ---- data state ----------------------------------------------------------
const [status, setStatus] = useState<ServiceStatusResponse | null>(null);
const [logData, setLogData] = useState<Fail2BanLogResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
// ---- toolbar state ------------------------------------------------------- // ---- toolbar state -------------------------------------------------------
const [linesCount, setLinesCount] = useState<number>(200); const [linesCount, setLinesCount] = useState<number>(200);
const [filterRaw, setFilterRaw] = useState<string>(""); const [filterRaw, setFilterRaw] = useState<string>("");
const [filterValue, setFilterValue] = useState<string>(""); const [filterValue, setFilterValue] = useState<string>("");
const { status, logData, error, refresh } = useServerHealth(linesCount, filterValue);
const [autoRefresh, setAutoRefresh] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false);
const [refreshInterval, setRefreshInterval] = useState(10); const [refreshInterval, setRefreshInterval] = useState(10);
@@ -204,33 +201,20 @@ export function ServerHealthSection(): React.JSX.Element {
// ---- fetch logic --------------------------------------------------------- // ---- fetch logic ---------------------------------------------------------
const fetchData = useCallback( const fetchData = useCallback(
async (showSpinner: boolean): Promise<void> => { async (showSpinner: boolean): Promise<void> => {
if (showSpinner) setIsRefreshing(true); if (showSpinner) {
setIsRefreshing(true);
}
try { try {
// Use allSettled so a log-read failure doesn't hide the service status. await refresh();
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.");
}
} finally { } finally {
if (showSpinner) setIsRefreshing(false); if (showSpinner) {
setIsRefreshing(false);
}
setLoading(false); setLoading(false);
} }
}, },
[linesCount, filterValue], [refresh],
); );
// ---- initial load -------------------------------------------------------- // ---- initial load --------------------------------------------------------

View File

@@ -13,7 +13,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ActivateJailDialog } from "../ActivateJailDialog"; import { ActivateJailDialog } from "../ActivateJailDialog";
import type { InactiveJail, JailActivationResponse, JailValidationResult } from "../../../types/config"; import type { ActivateJailRequest, InactiveJail, JailActivationResponse, JailValidationResult } from "../../../types/config";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks // Mocks
@@ -98,6 +98,15 @@ interface DialogProps {
open?: boolean; open?: boolean;
onClose?: () => void; onClose?: () => void;
onActivated?: () => void; onActivated?: () => void;
onValidate?: () => Promise<JailValidationResult>;
onActivate?: (payload: ActivateJailRequest) => Promise<{
name: string;
active: boolean;
message: string;
fail2ban_running: boolean;
validation_warnings: string[];
recovered?: boolean;
}>;
} }
function renderDialog({ function renderDialog({
@@ -105,6 +114,18 @@ function renderDialog({
open = true, open = true,
onClose = vi.fn(), onClose = vi.fn(),
onActivated = 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 = {}) { }: DialogProps = {}) {
return render( return render(
<FluentProvider theme={webLightTheme}> <FluentProvider theme={webLightTheme}>
@@ -113,6 +134,8 @@ function renderDialog({
open={open} open={open}
onClose={onClose} onClose={onClose}
onActivated={onActivated} onActivated={onActivated}
onValidate={onValidate}
onActivate={onActivate}
/> />
</FluentProvider>, </FluentProvider>,
); );

View File

@@ -18,6 +18,11 @@ vi.mock("../../../api/config", () => ({
vi.mock("../../../api/jails", () => ({ vi.mock("../../../api/jails", () => ({
fetchJails: vi.fn(), fetchJails: vi.fn(),
startJail: vi.fn(),
stopJail: vi.fn(),
setJailIdle: vi.fn(),
reloadJail: vi.fn(),
reloadAllJails: vi.fn(),
})); }));
import { assignFilterToJail } from "../../../api/config"; import { assignFilterToJail } from "../../../api/config";
@@ -68,6 +73,7 @@ function renderDialog(overrides: Partial<React.ComponentProps<typeof AssignFilte
open: true, open: true,
onClose: vi.fn(), onClose: vi.fn(),
onAssigned: vi.fn(), onAssigned: vi.fn(),
onAssign: vi.fn(async () => undefined),
...overrides, ...overrides,
}; };
return render( return render(

View File

@@ -52,6 +52,7 @@ function renderDialog(
const props = { const props = {
open: true, open: true,
onClose: vi.fn(), onClose: vi.fn(),
onCreateFilter: vi.fn(async () => createdFilter),
onCreate: vi.fn(), onCreate: vi.fn(),
...overrides, ...overrides,
}; };

View File

@@ -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<void>;
createAction: (payload: ActionCreateRequest) => Promise<ActionConfig>;
assignActionToJail: (jailName: string, payload: { action_name: string }, reload: boolean) => Promise<void>;
}
/**
* Load the action inventory and expose related action operations.
*/
export function useActionList(): UseActionListResult {
const [actions, setActions] = useState<ActionConfig[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = 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);
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<void> => {
await removeActionFromJail(jailName, actionName);
},
[],
);
const handleCreateAction = useCallback(
async (payload: ActionCreateRequest): Promise<ActionConfig> => {
return await createAction(payload);
},
[],
);
const handleAssignActionToJail = useCallback(
async (jailName: string, payload: { action_name: string }, reload: boolean): Promise<void> => {
await assignActionToJail(jailName, payload, reload);
},
[],
);
return {
actions,
loading,
error,
refresh,
removeActionFromJail: handleRemoveActionFromJail,
createAction: handleCreateAction,
assignActionToJail: handleAssignActionToJail,
};
}

View File

@@ -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<string>;
saveRawContent: (content: string) => Promise<void>;
}
/**
* Return raw config file operations for an action file.
*/
export function useActionRawFile(name: string): UseActionRawFileResult {
const fetchRawContent = useCallback(async (): Promise<string> => {
const result = await fetchActionFile(name);
return result.content;
}, [name]);
const saveRawContent = useCallback(
async (content: string): Promise<void> => {
await updateActionFile(name, { content });
},
[name],
);
return {
fetchRawContent,
saveRawContent,
};
}

View File

@@ -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<FilterConfig>;
assignFilterToJail: (jailName: string, payload: { filter_name: string }, reload: boolean) => Promise<void>;
}
/**
* Load the filter inventory and expose refresh semantics.
*/
export function useFilterList(): UseFilterListResult {
const [filters, setFilters] = useState<FilterConfig[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = 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);
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<FilterConfig> => {
return await createFilter(payload);
},
[],
);
const handleAssignFilterToJail = useCallback(
async (jailName: string, payload: { filter_name: string }, reload: boolean): Promise<void> => {
await assignFilterToJail(jailName, payload, reload);
},
[],
);
return {
filters,
loading,
error,
refresh,
createFilter: handleCreateFilter,
assignFilterToJail: handleAssignFilterToJail,
};
}

View File

@@ -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<string>;
saveRawContent: (content: string) => Promise<void>;
}
/**
* Return raw config file operations for a filter file.
*/
export function useFilterRawFile(name: string): UseFilterRawFileResult {
const fetchRawContent = useCallback(async (): Promise<string> => {
const result = await fetchFilterFile(name);
return result.content;
}, [name]);
const saveRawContent = useCallback(
async (content: string): Promise<void> => {
await updateFilterFile(name, { content });
},
[name],
);
return {
fetchRawContent,
saveRawContent,
};
}

View File

@@ -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<void>;
deleteJailLocalOverride: (name: string) => Promise<void>;
validateJailConfig: (name: string) => Promise<JailValidationResult>;
activateJail: (name: string, payload: ActivateJailRequest) => Promise<JailActivationResponse>;
createJailConfigFile: (payload: ConfFileCreateRequest) => Promise<void>;
}
/**
* Load inactive fail2ban jails and expose the admin actions used by the
* jail configuration tab.
*/
export function useJailAdmin(): UseJailAdminResult {
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
const [inactiveLoading, setInactiveLoading] = useState(false);
const [inactiveError, setInactiveError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<void> => {
await deactivateJail(name);
},
[],
);
const handleDeleteLocalOverride = useCallback(
async (name: string): Promise<void> => {
await deleteJailLocalOverride(name);
},
[],
);
const handleValidateJailConfig = useCallback(
async (name: string): Promise<JailValidationResult> => {
return await validateJailConfig(name);
},
[],
);
const handleActivateJail = useCallback(
async (name: string, payload: ActivateJailRequest): Promise<JailActivationResponse> => {
return await activateJail(name, payload);
},
[],
);
const handleCreateJailConfigFile = useCallback(
async (payload: ConfFileCreateRequest): Promise<void> => {
await createJailConfigFile(payload);
},
[],
);
return {
inactiveJails,
inactiveLoading,
inactiveError,
refreshInactiveJails,
deactivateJail: handleDeactivateJail,
deleteJailLocalOverride: handleDeleteLocalOverride,
validateJailConfig: handleValidateJailConfig,
activateJail: handleActivateJail,
createJailConfigFile: handleCreateJailConfigFile,
};
}

View File

@@ -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<void>;
deleteLogPath: (path: string) => Promise<void>;
fetchRawContent: () => Promise<string>;
saveRawContent: (content: string) => Promise<void>;
}
/**
* 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<void> => {
await addLogPath(jailName, payload);
},
[jailName],
);
const deletePath = useCallback(
async (path: string): Promise<void> => {
await deleteLogPath(jailName, path);
},
[jailName],
);
const fetchRawContent = useCallback(async (): Promise<string> => {
const result = await fetchJailConfigFileContent(`${jailName}.conf`);
return result.content;
}, [jailName]);
const saveRawContent = useCallback(
async (content: string): Promise<void> => {
await updateJailConfigFile(`${jailName}.conf`, { content });
},
[jailName],
);
return {
addLogPath: addLog,
deleteLogPath: deletePath,
fetchRawContent,
saveRawContent,
};
}

View File

@@ -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<void>;
}
/**
* Load service status and fail2ban log data for the server health panel.
*/
export function useServerHealth(
linesCount: number,
filterValue: string,
): UseServerHealthResult {
const [status, setStatus] = useState<ServiceStatusResponse | null>(null);
const [logData, setLogData] = useState<Fail2BanLogResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async (): Promise<void> => {
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 };
}