feat: frontend Actions Tab with structured API, assign/create/remove dialogs (Task 3.3)
- ActionsTab rewritten with master/detail layout (mirrors FiltersTab) - New AssignActionDialog and CreateActionDialog components - ActionConfig type extended with active, used_by_jails, source_file, has_local_override - New API functions: fetchActions, fetchAction, updateAction, createAction, deleteAction, assignActionToJail, removeActionFromJail - useActionConfig updated to use new structured endpoints - index.ts barrel exports updated
This commit is contained in:
@@ -7,8 +7,12 @@ import { ENDPOINTS } from "./endpoints";
|
|||||||
import type {
|
import type {
|
||||||
ActionConfig,
|
ActionConfig,
|
||||||
ActionConfigUpdate,
|
ActionConfigUpdate,
|
||||||
|
ActionCreateRequest,
|
||||||
|
ActionListResponse,
|
||||||
|
ActionUpdateRequest,
|
||||||
ActivateJailRequest,
|
ActivateJailRequest,
|
||||||
AddLogPathRequest,
|
AddLogPathRequest,
|
||||||
|
AssignActionRequest,
|
||||||
AssignFilterRequest,
|
AssignFilterRequest,
|
||||||
ConfFileContent,
|
ConfFileContent,
|
||||||
ConfFileCreateRequest,
|
ConfFileCreateRequest,
|
||||||
@@ -386,6 +390,108 @@ export async function updateParsedAction(
|
|||||||
await put<undefined>(ENDPOINTS.configActionParsed(name), update);
|
await put<undefined>(ENDPOINTS.configActionParsed(name), update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action discovery with active/inactive status (Task 3.1 / 3.2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all actions from action.d/ with active/inactive status.
|
||||||
|
*
|
||||||
|
* Active actions (those referenced by running jails) are returned first,
|
||||||
|
* followed by inactive ones. Both groups are sorted alphabetically.
|
||||||
|
*
|
||||||
|
* @returns ActionListResponse with all discovered actions and status.
|
||||||
|
*/
|
||||||
|
export async function fetchActions(): Promise<ActionListResponse> {
|
||||||
|
return get<ActionListResponse>(ENDPOINTS.configActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch full parsed detail for a single action.
|
||||||
|
*
|
||||||
|
* @param name - Action base name (e.g. "iptables" or "iptables.conf").
|
||||||
|
* @returns ActionConfig with active, used_by_jails, source_file populated.
|
||||||
|
*/
|
||||||
|
export async function fetchAction(name: string): Promise<ActionConfig> {
|
||||||
|
return get<ActionConfig>(ENDPOINTS.configAction(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an action's editable fields via the structured endpoint.
|
||||||
|
*
|
||||||
|
* Writes only the supplied fields to the ``.local`` override. Fields set
|
||||||
|
* to ``null`` are cleared; omitted fields are left unchanged.
|
||||||
|
*
|
||||||
|
* @param name - Action base name (e.g. ``"iptables"``)
|
||||||
|
* @param req - Partial update payload.
|
||||||
|
*/
|
||||||
|
export async function updateAction(
|
||||||
|
name: string,
|
||||||
|
req: ActionUpdateRequest
|
||||||
|
): Promise<void> {
|
||||||
|
await put<undefined>(ENDPOINTS.configAction(name), req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a brand-new user-defined action in ``action.d/{name}.local``.
|
||||||
|
*
|
||||||
|
* @param req - Name and optional lifecycle commands.
|
||||||
|
* @returns The newly created ActionConfig.
|
||||||
|
*/
|
||||||
|
export async function createAction(
|
||||||
|
req: ActionCreateRequest
|
||||||
|
): Promise<ActionConfig> {
|
||||||
|
return post<ActionConfig>(ENDPOINTS.configActions, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an action's ``.local`` override file.
|
||||||
|
*
|
||||||
|
* Only custom ``.local``-only actions can be deleted. Attempting to delete an
|
||||||
|
* action backed by a shipped ``.conf`` file returns 409.
|
||||||
|
*
|
||||||
|
* @param name - Action base name.
|
||||||
|
*/
|
||||||
|
export async function deleteAction(name: string): Promise<void> {
|
||||||
|
await del<undefined>(ENDPOINTS.configAction(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign an action to a jail by appending it to the jail's action list.
|
||||||
|
*
|
||||||
|
* @param jailName - Jail name.
|
||||||
|
* @param req - The action to assign with optional parameters.
|
||||||
|
* @param reload - When ``true``, trigger a fail2ban reload after writing.
|
||||||
|
*/
|
||||||
|
export async function assignActionToJail(
|
||||||
|
jailName: string,
|
||||||
|
req: AssignActionRequest,
|
||||||
|
reload = false
|
||||||
|
): Promise<void> {
|
||||||
|
const url = reload
|
||||||
|
? `${ENDPOINTS.configJailAction(jailName)}?reload=true`
|
||||||
|
: ENDPOINTS.configJailAction(jailName);
|
||||||
|
await post<undefined>(url, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an action from a jail's action list.
|
||||||
|
*
|
||||||
|
* @param jailName - Jail name.
|
||||||
|
* @param actionName - Action base name to remove.
|
||||||
|
* @param reload - When ``true``, trigger a fail2ban reload after writing.
|
||||||
|
*/
|
||||||
|
export async function removeActionFromJail(
|
||||||
|
jailName: string,
|
||||||
|
actionName: string,
|
||||||
|
reload = false
|
||||||
|
): Promise<void> {
|
||||||
|
const url = reload
|
||||||
|
? `${ENDPOINTS.configJailActionName(jailName, actionName)}?reload=true`
|
||||||
|
: ENDPOINTS.configJailActionName(jailName, actionName);
|
||||||
|
await del<undefined>(url);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parsed jail file config (Task 6.1 / 6.2)
|
// Parsed jail file config (Task 6.1 / 6.2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export const ENDPOINTS = {
|
|||||||
`/config/filters/${encodeURIComponent(name)}/parsed`,
|
`/config/filters/${encodeURIComponent(name)}/parsed`,
|
||||||
configJailFilter: (name: string): string =>
|
configJailFilter: (name: string): string =>
|
||||||
`/config/jails/${encodeURIComponent(name)}/filter`,
|
`/config/jails/${encodeURIComponent(name)}/filter`,
|
||||||
|
configJailAction: (name: string): string =>
|
||||||
|
`/config/jails/${encodeURIComponent(name)}/action`,
|
||||||
|
configJailActionName: (jailName: string, actionName: string): string =>
|
||||||
|
`/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`,
|
||||||
configActions: "/config/actions",
|
configActions: "/config/actions",
|
||||||
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
|
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
|
||||||
configActionParsed: (name: string): string =>
|
configActionParsed: (name: string): string =>
|
||||||
|
|||||||
@@ -1,87 +1,276 @@
|
|||||||
/**
|
/**
|
||||||
* ActionsTab — list/detail layout for action.d file editing.
|
* ActionsTab — master/detail layout for action.d discovery and editing.
|
||||||
*
|
*
|
||||||
* Left pane: action names with Active/Inactive badges. Active actions are
|
* Left pane: action names with Active/Inactive badges (active actions sorted
|
||||||
* those referenced by at least one running jail. Right pane: structured form
|
* to the top). Active badge also shows which jails reference the action.
|
||||||
* editor plus a collapsible raw-config editor.
|
* Right pane: structured action editor, "Assign to Jail" button, a
|
||||||
|
* "Remove from Jail" section, and a collapsible raw-config editor.
|
||||||
|
*
|
||||||
|
* A "Create Action" button at the top of the list pane opens a dialog for
|
||||||
|
* creating a new ``action.d/*.local`` file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SkeletonItem,
|
SkeletonItem,
|
||||||
Text,
|
|
||||||
tokens,
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { DocumentAdd24Regular } from "@fluentui/react-icons";
|
import { Add24Regular, Delete24Regular, LinkEdit24Regular } from "@fluentui/react-icons";
|
||||||
import { fetchActionFile, fetchActionFiles, updateActionFile } from "../../api/config";
|
import {
|
||||||
import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config";
|
fetchActionFile,
|
||||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
fetchActions,
|
||||||
|
removeActionFromJail,
|
||||||
|
updateActionFile,
|
||||||
|
} from "../../api/config";
|
||||||
|
import type { ActionConfig, ConfFileUpdateRequest } from "../../types/config";
|
||||||
import { ActionForm } from "./ActionForm";
|
import { ActionForm } from "./ActionForm";
|
||||||
|
import { AssignActionDialog } from "./AssignActionDialog";
|
||||||
import { ConfigListDetail } from "./ConfigListDetail";
|
import { ConfigListDetail } from "./ConfigListDetail";
|
||||||
|
import { CreateActionDialog } from "./CreateActionDialog";
|
||||||
import { RawConfigSection } from "./RawConfigSection";
|
import { RawConfigSection } from "./RawConfigSection";
|
||||||
import { useConfigStyles } from "./configStyles";
|
import { useConfigStyles } from "./configStyles";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab component for the form-based action.d editor.
|
* Build the badge label text shown next to each action in the list pane.
|
||||||
|
*/
|
||||||
|
function actionBadgeLabel(a: ActionConfig): string {
|
||||||
|
if (!a.active) return "Inactive";
|
||||||
|
if (a.used_by_jails.length === 0) return "Active";
|
||||||
|
return `Active — ${a.used_by_jails.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ActionDetail — right-pane detail for a selected action
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ActionDetailProps {
|
||||||
|
action: ActionConfig;
|
||||||
|
onAssignClick: () => void;
|
||||||
|
onRemovedFromJail: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail pane for a selected action: shows meta information, the structured
|
||||||
|
* editor, assign/remove jail actions, and a raw-config section.
|
||||||
|
*
|
||||||
|
* @param props - Component props.
|
||||||
|
* @returns JSX element.
|
||||||
|
*/
|
||||||
|
function ActionDetail({
|
||||||
|
action,
|
||||||
|
onAssignClick,
|
||||||
|
onRemovedFromJail,
|
||||||
|
}: ActionDetailProps): React.JSX.Element {
|
||||||
|
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 handleRemoveFromJail = useCallback(
|
||||||
|
(jailName: string): void => {
|
||||||
|
setRemovingJail(jailName);
|
||||||
|
setRemoveError(null);
|
||||||
|
removeActionFromJail(jailName, action.name)
|
||||||
|
.then(() => {
|
||||||
|
onRemovedFromJail();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setRemoveError(
|
||||||
|
err instanceof Error ? err.message : "Failed to remove action from jail.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setRemovingJail(null);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[action.name, onRemovedFromJail],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Meta information row */}
|
||||||
|
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||||
|
<Field label="Source file">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={action.source_file || action.filename}
|
||||||
|
className={styles.codeInput}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<Badge
|
||||||
|
appearance={action.active ? "filled" : "outline"}
|
||||||
|
color={action.active ? "success" : "informative"}
|
||||||
|
>
|
||||||
|
{action.active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
{action.has_local_override && (
|
||||||
|
<Badge appearance="tint" color="warning" size="small">
|
||||||
|
Has local override
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Structured editor */}
|
||||||
|
<ActionForm name={action.name} />
|
||||||
|
|
||||||
|
{/* Assign to jail action */}
|
||||||
|
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
icon={<LinkEdit24Regular />}
|
||||||
|
onClick={onAssignClick}
|
||||||
|
>
|
||||||
|
Assign to Jail
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove from jail section */}
|
||||||
|
{action.used_by_jails.length > 0 && (
|
||||||
|
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||||
|
{removeError !== null && (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
style={{ marginBottom: tokens.spacingVerticalXS }}
|
||||||
|
>
|
||||||
|
<MessageBarBody>{removeError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
{action.used_by_jails.map((jailName) => (
|
||||||
|
<div
|
||||||
|
key={jailName}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: "monospace", fontSize: 13 }}>
|
||||||
|
{jailName}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
size="small"
|
||||||
|
icon={<Delete24Regular />}
|
||||||
|
disabled={removingJail !== null}
|
||||||
|
onClick={() => { handleRemoveFromJail(jailName); }}
|
||||||
|
aria-label={`Remove action from ${jailName}`}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw config */}
|
||||||
|
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||||
|
<RawConfigSection
|
||||||
|
fetchContent={fetchRaw}
|
||||||
|
saveContent={saveRaw}
|
||||||
|
label="Raw Action Configuration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ActionsTab (public export)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab component for exploring and editing fail2ban action definitions.
|
||||||
*
|
*
|
||||||
* @returns JSX element.
|
* @returns JSX element.
|
||||||
*/
|
*/
|
||||||
export function ActionsTab(): React.JSX.Element {
|
export function ActionsTab(): React.JSX.Element {
|
||||||
const styles = useConfigStyles();
|
const [actions, setActions] = useState<ActionConfig[]>([]);
|
||||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||||
const { activeActions, loading: statusLoading } = useConfigActiveStatus();
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadActions = useCallback((): void => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
fetchActionFiles()
|
fetchActions()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (!ctrl.signal.aborted) {
|
if (!ctrl.signal.aborted) {
|
||||||
setFiles(resp.files);
|
setActions(resp.actions);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
if (!ctrl.signal.aborted) {
|
if (!ctrl.signal.aborted) {
|
||||||
setError(
|
setError(err instanceof Error ? err.message : "Failed to load actions");
|
||||||
err instanceof Error ? err.message : "Failed to load actions",
|
|
||||||
);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return (): void => {
|
|
||||||
ctrl.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchRaw = useCallback(
|
useEffect(() => {
|
||||||
async (name: string): Promise<string> => {
|
loadActions();
|
||||||
const result = await fetchActionFile(name);
|
return (): void => {
|
||||||
return result.content;
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [loadActions]);
|
||||||
|
|
||||||
|
/** The full ActionConfig for the currently selected name. */
|
||||||
|
const selectedAction = useMemo(
|
||||||
|
() => actions.find((a) => a.name === selectedName) ?? null,
|
||||||
|
[actions, selectedName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAssigned = useCallback((): void => {
|
||||||
|
setAssignOpen(false);
|
||||||
|
// Refresh action list so active status is up-to-date.
|
||||||
|
loadActions();
|
||||||
|
}, [loadActions]);
|
||||||
|
|
||||||
|
const handleCreated = useCallback(
|
||||||
|
(newAction: ActionConfig): void => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
setActions((prev) => [...prev, newAction]);
|
||||||
|
setSelectedName(newAction.name);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveRaw = useCallback(
|
const handleRemovedFromJail = useCallback((): void => {
|
||||||
async (name: string, content: string): Promise<void> => {
|
loadActions();
|
||||||
const req: ConfFileUpdateRequest = { content };
|
}, [loadActions]);
|
||||||
await updateActionFile(name, req);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading || statusLoading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Skeleton aria-label="Loading actions…">
|
<Skeleton aria-label="Loading actions…">
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
@@ -99,55 +288,51 @@ export function ActionsTab(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.length === 0) {
|
const listHeader = (
|
||||||
return (
|
<Button
|
||||||
<div className={styles.emptyState}>
|
appearance="outline"
|
||||||
<DocumentAdd24Regular
|
icon={<Add24Regular />}
|
||||||
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
size="small"
|
||||||
aria-hidden
|
onClick={() => { setCreateOpen(true); }}
|
||||||
/>
|
>
|
||||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
Create Action
|
||||||
No action files found.
|
</Button>
|
||||||
</Text>
|
);
|
||||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
|
||||||
Create a new action file in the Export tab.
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
appearance="primary"
|
|
||||||
onClick={() => {
|
|
||||||
window.location.hash = "#export";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Go to Export
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.tabContent}>
|
<>
|
||||||
<ConfigListDetail
|
<ConfigListDetail
|
||||||
items={files}
|
items={actions}
|
||||||
isActive={(f) => activeActions.has(f.name)}
|
isActive={(a) => a.active}
|
||||||
|
itemBadgeLabel={actionBadgeLabel}
|
||||||
selectedName={selectedName}
|
selectedName={selectedName}
|
||||||
onSelect={setSelectedName}
|
onSelect={setSelectedName}
|
||||||
loading={false}
|
loading={false}
|
||||||
error={null}
|
error={null}
|
||||||
|
listHeader={listHeader}
|
||||||
>
|
>
|
||||||
{selectedName !== null && (
|
{selectedAction !== null && (
|
||||||
<div>
|
<ActionDetail
|
||||||
<ActionForm name={selectedName} />
|
action={selectedAction}
|
||||||
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
onAssignClick={() => { setAssignOpen(true); }}
|
||||||
<RawConfigSection
|
onRemovedFromJail={handleRemovedFromJail}
|
||||||
fetchContent={() => fetchRaw(selectedName)}
|
/>
|
||||||
saveContent={(content) => saveRaw(selectedName, content)}
|
|
||||||
label="Raw Action Configuration"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ConfigListDetail>
|
</ConfigListDetail>
|
||||||
</div>
|
|
||||||
|
<AssignActionDialog
|
||||||
|
actionName={selectedName}
|
||||||
|
open={assignOpen}
|
||||||
|
onClose={() => { setAssignOpen(false); }}
|
||||||
|
onAssigned={handleAssigned}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateActionDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => { setCreateOpen(false); }}
|
||||||
|
onCreate={handleCreated}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
210
frontend/src/components/config/AssignActionDialog.tsx
Normal file
210
frontend/src/components/config/AssignActionDialog.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* AssignActionDialog — dialog for assigning an action to a fail2ban jail.
|
||||||
|
*
|
||||||
|
* Fetches the list of active jails for the dropdown, then calls
|
||||||
|
* ``POST /api/config/jails/{jail_name}/action`` on confirmation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogSurface,
|
||||||
|
DialogTitle,
|
||||||
|
Field,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Select,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { assignActionToJail } from "../../api/config";
|
||||||
|
import { fetchJails } from "../../api/jails";
|
||||||
|
import type { AssignActionRequest } from "../../types/config";
|
||||||
|
import type { JailSummary } from "../../types/jail";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AssignActionDialogProps {
|
||||||
|
/**
|
||||||
|
* The action base name to assign (e.g. ``"iptables"``). Set to ``null``
|
||||||
|
* when the dialog is closed.
|
||||||
|
*/
|
||||||
|
actionName: string | null;
|
||||||
|
/** Whether the dialog is currently open. */
|
||||||
|
open: boolean;
|
||||||
|
/** Called when the dialog should close without taking action. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called after the action has been successfully assigned. */
|
||||||
|
onAssigned: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation dialog for assigning an action to a jail.
|
||||||
|
*
|
||||||
|
* Fetches running jails from the API for the jail selector. The user can
|
||||||
|
* optionally request a fail2ban reload immediately after the assignment is
|
||||||
|
* written.
|
||||||
|
*
|
||||||
|
* @param props - Component props.
|
||||||
|
* @returns JSX element.
|
||||||
|
*/
|
||||||
|
export function AssignActionDialog({
|
||||||
|
actionName,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAssigned,
|
||||||
|
}: AssignActionDialogProps): React.JSX.Element {
|
||||||
|
const [jails, setJails] = useState<JailSummary[]>([]);
|
||||||
|
const [jailsLoading, setJailsLoading] = useState(false);
|
||||||
|
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.
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const handleClose = useCallback((): void => {
|
||||||
|
if (submitting) return;
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}, [submitting, onClose]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback((): void => {
|
||||||
|
if (!actionName || !selectedJail || submitting) return;
|
||||||
|
|
||||||
|
const req: AssignActionRequest = { action_name: actionName };
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
assignActionToJail(selectedJail, req, reload)
|
||||||
|
.then(() => {
|
||||||
|
onAssigned();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : "Failed to assign action.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setSubmitting(false);
|
||||||
|
});
|
||||||
|
}, [actionName, selectedJail, reload, submitting, onAssigned]);
|
||||||
|
|
||||||
|
const canConfirm = selectedJail !== "" && !submitting && !jailsLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>Assign Action to Jail</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{actionName !== null && (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
size={300}
|
||||||
|
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||||
|
>
|
||||||
|
Assign action{" "}
|
||||||
|
<Text weight="semibold" style={{ fontFamily: "monospace" }}>
|
||||||
|
{actionName}
|
||||||
|
</Text>{" "}
|
||||||
|
to a jail. This appends the action to the jail's{" "}
|
||||||
|
<code>action</code> list in its <code>.local</code> override file.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error !== null && (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
<MessageBarBody>{error}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Target jail"
|
||||||
|
required
|
||||||
|
hint="Only currently enabled jails are listed."
|
||||||
|
>
|
||||||
|
{jailsLoading ? (
|
||||||
|
<Spinner size="extra-small" label="Loading jails…" />
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={selectedJail}
|
||||||
|
onChange={(_e, d) => { setSelectedJail(d.value); }}
|
||||||
|
aria-label="Target jail"
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
— select a jail —
|
||||||
|
</option>
|
||||||
|
{jails.map((j) => (
|
||||||
|
<option key={j.name} value={j.name}>
|
||||||
|
{j.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label="Reload fail2ban after assigning"
|
||||||
|
checked={reload}
|
||||||
|
onChange={(_e, d) => { setReload(Boolean(d.checked)); }}
|
||||||
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
icon={submitting ? <Spinner size="extra-small" /> : undefined}
|
||||||
|
>
|
||||||
|
{submitting ? "Assigning…" : "Assign"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/src/components/config/CreateActionDialog.tsx
Normal file
203
frontend/src/components/config/CreateActionDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* CreateActionDialog — dialog for creating a new user-defined fail2ban action.
|
||||||
|
*
|
||||||
|
* Asks for an action name and optional lifecycle commands, then calls
|
||||||
|
* ``POST /api/config/actions`` on confirmation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogSurface,
|
||||||
|
DialogTitle,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
|
import { createAction } from "../../api/config";
|
||||||
|
import type { ActionConfig, ActionCreateRequest } from "../../types/config";
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CreateActionDialogProps {
|
||||||
|
/** Whether the dialog is currently open. */
|
||||||
|
open: boolean;
|
||||||
|
/** Called when the dialog should close without taking action. */
|
||||||
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Called after the action has been successfully created.
|
||||||
|
*
|
||||||
|
* @param action - The newly created ActionConfig returned by the API.
|
||||||
|
*/
|
||||||
|
onCreate: (action: ActionConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog for creating a new action in ``action.d/{name}.local``.
|
||||||
|
*
|
||||||
|
* The name field accepts a plain base name (e.g. ``my-action``); the ``.local``
|
||||||
|
* extension is added by the backend.
|
||||||
|
*
|
||||||
|
* @param props - Component props.
|
||||||
|
* @returns JSX element.
|
||||||
|
*/
|
||||||
|
export function CreateActionDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: CreateActionDialogProps): React.JSX.Element {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [actionban, setActionban] = useState("");
|
||||||
|
const [actionunban, setActionunban] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Reset form when the dialog opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName("");
|
||||||
|
setActionban("");
|
||||||
|
setActionunban("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleClose = useCallback((): void => {
|
||||||
|
if (submitting) return;
|
||||||
|
onClose();
|
||||||
|
}, [submitting, onClose]);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback((): void => {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName || submitting) return;
|
||||||
|
|
||||||
|
const req: ActionCreateRequest = {
|
||||||
|
name: trimmedName,
|
||||||
|
actionban: actionban.trim() || null,
|
||||||
|
actionunban: actionunban.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
createAction(req)
|
||||||
|
.then((action) => {
|
||||||
|
onCreate(action);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : "Failed to create action.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setSubmitting(false);
|
||||||
|
});
|
||||||
|
}, [name, actionban, actionunban, submitting, onCreate]);
|
||||||
|
|
||||||
|
const canConfirm = name.trim() !== "" && !submitting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(_e, data) => { if (!data.open) handleClose(); }}>
|
||||||
|
<DialogSurface>
|
||||||
|
<DialogBody>
|
||||||
|
<DialogTitle>Create Action</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
size={300}
|
||||||
|
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||||
|
>
|
||||||
|
Creates a new action definition at{" "}
|
||||||
|
<code>action.d/<name>.local</code>.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error !== null && (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
style={{ marginBottom: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
<MessageBarBody>{error}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Action name"
|
||||||
|
required
|
||||||
|
hint='Base name without extension, e.g. "my-action". Must not already exist.'
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(_e, d) => { setName(d.value); }}
|
||||||
|
placeholder="my-action"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="actionban command"
|
||||||
|
hint="Command executed when an IP is banned. Leave blank to add later."
|
||||||
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={actionban}
|
||||||
|
onChange={(_e, d) => { setActionban(d.value); }}
|
||||||
|
placeholder={"echo ban <ip>"}
|
||||||
|
rows={3}
|
||||||
|
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="actionunban command"
|
||||||
|
hint="Command executed when a ban is removed."
|
||||||
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={actionunban}
|
||||||
|
onChange={(_e, d) => { setActionunban(d.value); }}
|
||||||
|
placeholder={"echo unban <ip>"}
|
||||||
|
rows={3}
|
||||||
|
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canConfirm}
|
||||||
|
icon={submitting ? <Spinner size="extra-small" /> : undefined}
|
||||||
|
>
|
||||||
|
{submitting ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogSurface>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ const mockConfig: ActionConfig = {
|
|||||||
actionflush: null,
|
actionflush: null,
|
||||||
definition_vars: {},
|
definition_vars: {},
|
||||||
init_vars: {},
|
init_vars: {},
|
||||||
|
active: false,
|
||||||
|
used_by_jails: [],
|
||||||
|
source_file: "iptables.conf",
|
||||||
|
has_local_override: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderForm(name: string) {
|
function renderForm(name: string) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export { ActionForm } from "./ActionForm";
|
|||||||
export type { ActionFormProps } from "./ActionForm";
|
export type { ActionFormProps } from "./ActionForm";
|
||||||
export { ActivateJailDialog } from "./ActivateJailDialog";
|
export { ActivateJailDialog } from "./ActivateJailDialog";
|
||||||
export type { ActivateJailDialogProps } from "./ActivateJailDialog";
|
export type { ActivateJailDialogProps } from "./ActivateJailDialog";
|
||||||
|
export { AssignActionDialog } from "./AssignActionDialog";
|
||||||
|
export type { AssignActionDialogProps } from "./AssignActionDialog";
|
||||||
export { AssignFilterDialog } from "./AssignFilterDialog";
|
export { AssignFilterDialog } from "./AssignFilterDialog";
|
||||||
export type { AssignFilterDialogProps } from "./AssignFilterDialog";
|
export type { AssignFilterDialogProps } from "./AssignFilterDialog";
|
||||||
export { AutoSaveIndicator } from "./AutoSaveIndicator";
|
export { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||||
@@ -18,6 +20,8 @@ export { ConfFilesTab } from "./ConfFilesTab";
|
|||||||
export type { ConfFilesTabProps } from "./ConfFilesTab";
|
export type { ConfFilesTabProps } from "./ConfFilesTab";
|
||||||
export { ConfigListDetail } from "./ConfigListDetail";
|
export { ConfigListDetail } from "./ConfigListDetail";
|
||||||
export type { ConfigListDetailProps } from "./ConfigListDetail";
|
export type { ConfigListDetailProps } from "./ConfigListDetail";
|
||||||
|
export { CreateActionDialog } from "./CreateActionDialog";
|
||||||
|
export type { CreateActionDialogProps } from "./CreateActionDialog";
|
||||||
export { CreateFilterDialog } from "./CreateFilterDialog";
|
export { CreateFilterDialog } from "./CreateFilterDialog";
|
||||||
export type { CreateFilterDialogProps } from "./CreateFilterDialog";
|
export type { CreateFilterDialogProps } from "./CreateFilterDialog";
|
||||||
export { ExportTab } from "./ExportTab";
|
export { ExportTab } from "./ExportTab";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchParsedAction, updateParsedAction } from "../api/config";
|
import { fetchAction, updateAction } from "../api/config";
|
||||||
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
|
import type { ActionConfig, ActionConfigUpdate } from "../types/config";
|
||||||
|
|
||||||
export interface UseActionConfigResult {
|
export interface UseActionConfigResult {
|
||||||
@@ -37,7 +37,7 @@ export function useActionConfig(name: string): UseActionConfigResult {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
fetchParsedAction(name)
|
fetchAction(name)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!ctrl.signal.aborted) {
|
if (!ctrl.signal.aborted) {
|
||||||
setConfig(data);
|
setConfig(data);
|
||||||
@@ -64,7 +64,7 @@ export function useActionConfig(name: string): UseActionConfigResult {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
await updateParsedAction(name, update);
|
await updateAction(name, update);
|
||||||
setConfig((prev) =>
|
setConfig((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -348,6 +348,74 @@ export interface ActionConfig {
|
|||||||
definition_vars: Record<string, string>;
|
definition_vars: Record<string, string>;
|
||||||
/** [Init] section key-value pairs. */
|
/** [Init] section key-value pairs. */
|
||||||
init_vars: Record<string, string>;
|
init_vars: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* True when this action is referenced by at least one currently running jail.
|
||||||
|
*/
|
||||||
|
active: boolean;
|
||||||
|
/**
|
||||||
|
* Names of currently enabled jails that reference this action.
|
||||||
|
* Empty when active is false.
|
||||||
|
*/
|
||||||
|
used_by_jails: string[];
|
||||||
|
/** Absolute path to the .conf source file. Empty string when not computed. */
|
||||||
|
source_file: string;
|
||||||
|
/** True when a .local override file exists alongside the base .conf. */
|
||||||
|
has_local_override: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for GET /api/config/actions.
|
||||||
|
* Lists all discovered actions with active/inactive status.
|
||||||
|
*/
|
||||||
|
export interface ActionListResponse {
|
||||||
|
actions: ActionConfig[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for ``PUT /api/config/actions/{name}``.
|
||||||
|
*
|
||||||
|
* Only editable lifecycle fields are accepted.
|
||||||
|
* Fields set to ``null`` or omitted leave the existing value unchanged.
|
||||||
|
*/
|
||||||
|
export interface ActionUpdateRequest {
|
||||||
|
actionstart?: string | null;
|
||||||
|
actionstop?: string | null;
|
||||||
|
actioncheck?: string | null;
|
||||||
|
actionban?: string | null;
|
||||||
|
actionunban?: string | null;
|
||||||
|
actionflush?: string | null;
|
||||||
|
definition_vars?: Record<string, string> | null;
|
||||||
|
init_vars?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for ``POST /api/config/actions``.
|
||||||
|
*
|
||||||
|
* Creates a new user-defined action at ``action.d/{name}.local``.
|
||||||
|
*/
|
||||||
|
export interface ActionCreateRequest {
|
||||||
|
/** Base name without extension (e.g. ``my-action``). */
|
||||||
|
name: string;
|
||||||
|
actionstart?: string | null;
|
||||||
|
actionstop?: string | null;
|
||||||
|
actioncheck?: string | null;
|
||||||
|
actionban?: string | null;
|
||||||
|
actionunban?: string | null;
|
||||||
|
actionflush?: string | null;
|
||||||
|
definition_vars?: Record<string, string> | null;
|
||||||
|
init_vars?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for ``POST /api/config/jails/{jail_name}/action``.
|
||||||
|
*
|
||||||
|
* Adds an action to a jail's action list.
|
||||||
|
*/
|
||||||
|
export interface AssignActionRequest {
|
||||||
|
action_name: string;
|
||||||
|
/** Optional parameters for the action (e.g. ``{ port: "ssh" }``). */
|
||||||
|
params?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user