Task 11: Remove direct API calls from components
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 --------------------------------------------------------
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
91
frontend/src/hooks/useActionList.ts
Normal file
91
frontend/src/hooks/useActionList.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
frontend/src/hooks/useActionRawFile.ts
Normal file
32
frontend/src/hooks/useActionRawFile.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
frontend/src/hooks/useFilterList.ts
Normal file
82
frontend/src/hooks/useFilterList.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
frontend/src/hooks/useFilterRawFile.ts
Normal file
32
frontend/src/hooks/useFilterRawFile.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
123
frontend/src/hooks/useJailAdmin.ts
Normal file
123
frontend/src/hooks/useJailAdmin.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
57
frontend/src/hooks/useJailConfigOperations.ts
Normal file
57
frontend/src/hooks/useJailConfigOperations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
53
frontend/src/hooks/useServerHealth.ts
Normal file
53
frontend/src/hooks/useServerHealth.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user