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

@@ -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<void>;
}
/**
@@ -73,27 +70,15 @@ function ActionDetail({
const styles = useConfigStyles();
const [removingJail, setRemovingJail] = useState<string | null>(null);
const [removeError, setRemoveError] = useState<string | null>(null);
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 { 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 */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
fetchContent={fetchRawContent}
saveContent={saveRawContent}
label="Raw Action Configuration"
/>
</div>
@@ -193,42 +178,18 @@ function ActionDetail({
* @returns JSX element.
*/
export function ActionsTab(): React.JSX.Element {
const [actions, setActions] = useState<ActionConfig[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
actions,
loading,
error,
refresh,
removeActionFromJail,
createAction,
assignActionToJail,
} = useActionList();
const [selectedName, setSelectedName] = useState<string | null>(null);
const [assignOpen, setAssignOpen] = 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. */
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<void> => {
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);
}}
/>
<CreateActionDialog
open={createOpen}
onClose={() => { setCreateOpen(false); }}
onCreateAction={createAction}
onCreate={handleCreated}
/>
</>

View File

@@ -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<JailValidationResult>;
/** Activates the jail with optional override fields. */
onActivate: (payload: ActivateJailRequest) => Promise<JailActivationResponse>;
}
// ---------------------------------------------------------------------------
@@ -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) {

View File

@@ -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<void>;
}
// ---------------------------------------------------------------------------
@@ -66,35 +66,29 @@ export function AssignActionDialog({
open,
onClose,
onAssigned,
onAssign,
}: AssignActionDialogProps): React.JSX.Element {
const [jails, setJails] = useState<JailSummary[]>([]);
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<string | null>(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({
<option value="" disabled>
select a jail
</option>
{jails.map((j) => (
{activeJails.map((j) => (
<option key={j.name} value={j.name}>
{j.name}
</option>

View File

@@ -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<void>;
}
// ---------------------------------------------------------------------------
@@ -66,35 +66,29 @@ export function AssignFilterDialog({
open,
onClose,
onAssigned,
onAssign,
}: AssignFilterDialogProps): React.JSX.Element {
const [jails, setJails] = useState<JailSummary[]>([]);
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<string | null>(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({
<option value="" disabled>
select a jail
</option>
{jails.map((j) => (
{activeJails.map((j) => (
<option key={j.name} value={j.name}>
{j.name}
</option>

View File

@@ -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<ActionConfig>;
/**
* 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);
})

View File

@@ -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<FilterConfig>;
/**
* 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);
})

View File

@@ -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<void>;
/** 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();
})

View File

@@ -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<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],
);
const { fetchRawContent, saveRawContent } = useFilterRawFile(filter.name);
return (
<div>
@@ -114,8 +102,8 @@ function FilterDetail({
{/* Raw config */}
<div style={{ marginTop: tokens.spacingVerticalL }}>
<RawConfigSection
fetchContent={fetchRaw}
saveContent={saveRaw}
fetchContent={fetchRawContent}
saveContent={saveRawContent}
label="Raw Filter Configuration"
/>
</div>
@@ -133,42 +121,17 @@ function FilterDetail({
* @returns JSX element.
*/
export function FiltersTab(): React.JSX.Element {
const [filters, setFilters] = useState<FilterConfig[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
filters,
loading,
error,
refresh,
createFilter,
assignFilterToJail,
} = useFilterList();
const [selectedName, setSelectedName] = useState<string | null>(null);
const [assignOpen, setAssignOpen] = 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. */
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);
}}
/>
<CreateFilterDialog
open={createOpen}
onClose={() => { setCreateOpen(false); }}
onCreateFilter={createFilter}
onCreate={handleCreated}
/>
</>

View File

@@ -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<JailConfigUpdate>(
() => ({
@@ -249,16 +247,14 @@ function JailConfigDetail({
// Raw config file fetch/save helpers — uses jail.d/<name>.conf convention.
const fetchRaw = useCallback(async (): Promise<string> => {
const result = await fetchJailConfigFileContent(`${jail.name}.conf`);
return result.content;
}, [jail.name]);
return await fetchRawContent();
}, [fetchRawContent]);
const saveRaw = useCallback(
async (content: string): Promise<void> => {
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<JailValidationResult>;
}
/**
@@ -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<string | null>(null);
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 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<void> => {
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<void> => {
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<Array<{ name: string; kind: "active" | "inactive" }>>(() => {
@@ -924,6 +922,7 @@ export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
: undefined
}
onValidate={() => validateJailConfig(selectedInactiveJail.name)}
/>
) : null}
</ConfigListDetail>
@@ -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);
}}
/>
<CreateJailDialog
open={createDialogOpen}
onClose={() => { setCreateDialogOpen(false); }}
onCreateJail={async (payload) => {
await createJailConfigFile(payload);
}}
onCreated={() => {
setCreateDialogOpen(false);
refresh();
loadInactive();
refreshInactiveJails();
}}
/>
</div>

View File

@@ -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<ServiceStatusResponse | null>(null);
const [logData, setLogData] = useState<Fail2BanLogResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// ---- toolbar state -------------------------------------------------------
const [linesCount, setLinesCount] = useState<number>(200);
const [filterRaw, setFilterRaw] = useState<string>("");
const [filterValue, setFilterValue] = useState<string>("");
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<void> => {
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 --------------------------------------------------------

View File

@@ -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<JailValidationResult>;
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(
<FluentProvider theme={webLightTheme}>
@@ -113,6 +134,8 @@ function renderDialog({
open={open}
onClose={onClose}
onActivated={onActivated}
onValidate={onValidate}
onActivate={onActivate}
/>
</FluentProvider>,
);

View File

@@ -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<React.ComponentProps<typeof AssignFilte
open: true,
onClose: vi.fn(),
onAssigned: vi.fn(),
onAssign: vi.fn(async () => undefined),
...overrides,
};
return render(

View File

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