Task 11: Remove direct API calls from components
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 --------------------------------------------------------
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -52,6 +52,7 @@ function renderDialog(
|
||||
const props = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onCreateFilter: vi.fn(async () => createdFilter),
|
||||
onCreate: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user