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:
2026-03-13 19:21:58 +01:00
parent f7cc130432
commit 6e35c5d269
9 changed files with 861 additions and 77 deletions

View File

@@ -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)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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 =>

View File

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

View 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&apos;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>
);
}

View 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/&lt;name&gt;.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>
);
}

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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
? { ? {

View File

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