Add Configuration Actions tab for shell and script action definitions.
Admins can manage built-in and custom actions with placeholders, ENV vars, and actions.create/update/delete permissions.
This commit is contained in:
+197
-9
@@ -133,7 +133,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
@@ -300,14 +300,14 @@
|
||||
|
||||
.config-tab-panel {
|
||||
min-width: 0;
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-width: 28rem;
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.config-field label {
|
||||
@@ -347,7 +347,6 @@
|
||||
|
||||
.config-empty {
|
||||
margin: 0;
|
||||
max-width: 36rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
@@ -432,7 +431,6 @@
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
max-width: 40rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -448,7 +446,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
max-width: 36rem;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.resource-add-form .field {
|
||||
@@ -566,14 +565,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.security-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.groups-list {
|
||||
@@ -652,11 +651,13 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groups-edit:disabled,
|
||||
.groups-delete:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.groups-edit:disabled:hover,
|
||||
.groups-delete:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
@@ -732,11 +733,198 @@
|
||||
background: var(--ctp-mauve-hover);
|
||||
}
|
||||
|
||||
.groups-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.groups-save:disabled:hover {
|
||||
background: var(--ctp-mauve);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.groups-save:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actions-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions-list-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.actions-list-header .config-hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions-body-preview {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||
monospace;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.actions-placeholders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 0 0 24.75rem;
|
||||
max-width: 27rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-mantle);
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 52rem) {
|
||||
.actions-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions-placeholders {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.actions-placeholders-intro {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions-placeholder-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.actions-placeholder-list li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.actions-placeholder-list code {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||
monospace;
|
||||
color: var(--ctp-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.actions-placeholder-example {
|
||||
color: var(--ctp-overlay1);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions-type-modal {
|
||||
width: min(28rem, 100%);
|
||||
}
|
||||
|
||||
.actions-editor-modal {
|
||||
width: min(36rem, 100%);
|
||||
max-height: min(90vh, 40rem);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.actions-type-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
}
|
||||
|
||||
.actions-type-choice {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions-type-choice:hover {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.actions-type-choice:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actions-type-choice strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.actions-type-choice span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions-script-input {
|
||||
resize: vertical;
|
||||
min-height: 8rem;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||
monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.actions-env-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.actions-env-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -744,7 +932,7 @@
|
||||
gap: 0.6rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-action-revert,
|
||||
|
||||
@@ -33,6 +33,9 @@ function stubFetchOk() {
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'actions.create',
|
||||
'actions.update',
|
||||
'actions.delete',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
@@ -74,6 +77,9 @@ function stubFetchOk() {
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'actions.create',
|
||||
'actions.update',
|
||||
'actions.delete',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
@@ -145,6 +151,38 @@ function stubFetchOk() {
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/actions')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
actions: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
name: 'Uptime',
|
||||
description: 'Show how long the target system has been running.',
|
||||
kind: 'shell',
|
||||
body: 'uptime',
|
||||
env: [],
|
||||
builtin: true,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'custom-1',
|
||||
name: 'Echo host',
|
||||
description: 'Print the target IP',
|
||||
kind: 'shell',
|
||||
body: 'echo {{node.ip}}',
|
||||
env: [{ name: 'APP_HOME', value: '/opt/app' }],
|
||||
builtin: false,
|
||||
created_at: '2026-01-02T00:00:00.000Z',
|
||||
updated_at: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -355,6 +393,7 @@ describe('App', () => {
|
||||
'Users',
|
||||
'Roles',
|
||||
'Groups',
|
||||
'Actions',
|
||||
'Security',
|
||||
'Integrations',
|
||||
'Network',
|
||||
@@ -378,6 +417,9 @@ describe('App', () => {
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'actions.create',
|
||||
'actions.update',
|
||||
'actions.delete',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
@@ -395,6 +437,33 @@ describe('App', () => {
|
||||
expect(screen.getByLabelText('roles.manage')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Actions' }))
|
||||
expect(screen.getByRole('tab', { name: 'Actions' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
)
|
||||
expect(await screen.findByText('Uptime')).toBeInTheDocument()
|
||||
expect(screen.getByText('Echo host')).toBeInTheDocument()
|
||||
expect(screen.getByText('{{cc.host}}')).toBeInTheDocument()
|
||||
expect(screen.getByText('{{secret.NAME}}')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add Action…' }),
|
||||
).toBeInTheDocument()
|
||||
const actionDeleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
expect(actionDeleteButtons[0]).toBeDisabled()
|
||||
expect(actionDeleteButtons[1]).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add Action…' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Add Action' }),
|
||||
).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /Shell Command/i }))
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'New shell command' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Command')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Security' }))
|
||||
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
confirmMyTOTP,
|
||||
createNode,
|
||||
createUser,
|
||||
createAction,
|
||||
deleteAction,
|
||||
deleteGroup,
|
||||
deleteNode,
|
||||
deleteUser,
|
||||
DEFAULT_NETWORK_SETTINGS,
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
disableMyTOTP,
|
||||
fetchActions,
|
||||
fetchGroups,
|
||||
fetchMe,
|
||||
fetchNetwork,
|
||||
@@ -21,10 +24,14 @@ import {
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
isReauthRequiredError,
|
||||
patchAction,
|
||||
patchUser,
|
||||
reauth,
|
||||
testNodeSSH,
|
||||
type AccessMode,
|
||||
type Action,
|
||||
type ActionEnvVar,
|
||||
type ActionKind,
|
||||
type Group,
|
||||
type GroupScopeKind,
|
||||
type MeResponse,
|
||||
@@ -91,6 +98,7 @@ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'roles', label: 'Roles' },
|
||||
{ id: 'groups', label: 'Groups' },
|
||||
{ id: 'actions', label: 'Actions' },
|
||||
{ id: 'security', label: 'Security' },
|
||||
{ id: 'integrations', label: 'Integrations' },
|
||||
{ id: 'network', label: 'Network' },
|
||||
@@ -116,6 +124,9 @@ const DEFAULT_PERMISSIONS = [
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'actions.create',
|
||||
'actions.update',
|
||||
'actions.delete',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
@@ -984,6 +995,542 @@ function OverviewConfigTab({
|
||||
)
|
||||
}
|
||||
|
||||
const ACTION_PLACEHOLDERS: ReadonlyArray<{
|
||||
token: string
|
||||
description: string
|
||||
example: string
|
||||
}> = [
|
||||
{
|
||||
token: '{{cc.host}}',
|
||||
description: 'ClusterCanvas public hostname',
|
||||
example: 'canvas.example.com',
|
||||
},
|
||||
{
|
||||
token: '{{cc.ip}}',
|
||||
description: 'ClusterCanvas listen / resolved IP',
|
||||
example: '192.168.1.10',
|
||||
},
|
||||
{
|
||||
token: '{{node.host}}',
|
||||
description: 'Target node hostname / address as stored',
|
||||
example: 'vm-web-01',
|
||||
},
|
||||
{
|
||||
token: '{{node.ip}}',
|
||||
description: 'Target node host IP',
|
||||
example: '10.0.0.5',
|
||||
},
|
||||
{
|
||||
token: '{{node.username}}',
|
||||
description: 'SSH username on the target node',
|
||||
example: 'root',
|
||||
},
|
||||
{
|
||||
token: '{{env.NAME}}',
|
||||
description: 'Value of an ENV var defined on this action',
|
||||
example: '{{env.APP_HOME}} → /opt/app',
|
||||
},
|
||||
{
|
||||
token: '{{secret.NAME}}',
|
||||
description:
|
||||
'Reserved for the future Secrets page (not resolved yet)',
|
||||
example: '{{secret.db_password}}',
|
||||
},
|
||||
]
|
||||
|
||||
function emptyEnvRow(): ActionEnvVar {
|
||||
return { name: '', value: '' }
|
||||
}
|
||||
|
||||
function ActionsConfigTab() {
|
||||
const [actions, setActions] = useState<Action[]>([])
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [showTypePicker, setShowTypePicker] = useState(false)
|
||||
const [editorKind, setEditorKind] = useState<ActionKind | null>(null)
|
||||
const [editingActionId, setEditingActionId] = useState<string | null>(null)
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftDescription, setDraftDescription] = useState('')
|
||||
const [draftBody, setDraftBody] = useState('')
|
||||
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isEditing = editingActionId !== null
|
||||
const showEditor = editorKind !== null
|
||||
const canCreateActions = Boolean(me?.permissions?.includes('actions.create'))
|
||||
const canUpdateActions = Boolean(me?.permissions?.includes('actions.update'))
|
||||
const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete'))
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadActions() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const [actionsResponse, meResponse] = await Promise.all([
|
||||
fetchActions(),
|
||||
fetchMe(),
|
||||
])
|
||||
if (!isCancelled) {
|
||||
setActions([...actionsResponse.actions])
|
||||
setMe(meResponse)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : 'Failed to load actions',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadActions()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
function clearEditor() {
|
||||
setShowTypePicker(false)
|
||||
setEditorKind(null)
|
||||
setEditingActionId(null)
|
||||
setDraftName('')
|
||||
setDraftDescription('')
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
function openTypePicker() {
|
||||
clearEditor()
|
||||
setShowTypePicker(true)
|
||||
}
|
||||
|
||||
function startCreate(kind: ActionKind) {
|
||||
setShowTypePicker(false)
|
||||
setEditorKind(kind)
|
||||
setEditingActionId(null)
|
||||
setDraftName('')
|
||||
setDraftDescription('')
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
function startEdit(action: Action) {
|
||||
if (action.builtin || !canUpdateActions) {
|
||||
return
|
||||
}
|
||||
setShowTypePicker(false)
|
||||
setEditorKind(action.kind)
|
||||
setEditingActionId(action.id)
|
||||
setDraftName(action.name)
|
||||
setDraftDescription(action.description)
|
||||
setDraftBody(action.body)
|
||||
setDraftEnv(
|
||||
action.env.length > 0
|
||||
? action.env.map((envVar) => ({ ...envVar }))
|
||||
: [emptyEnvRow()],
|
||||
)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
function updateEnvRow(index: number, field: 'name' | 'value', value: string) {
|
||||
setDraftEnv((previous) =>
|
||||
previous.map((row, rowIndex) =>
|
||||
rowIndex === index ? { ...row, [field]: value } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function addEnvRow() {
|
||||
setDraftEnv((previous) => [...previous, emptyEnvRow()])
|
||||
}
|
||||
|
||||
function removeEnvRow(index: number) {
|
||||
setDraftEnv((previous) => {
|
||||
if (previous.length <= 1) {
|
||||
return [emptyEnvRow()]
|
||||
}
|
||||
return previous.filter((_, rowIndex) => rowIndex !== index)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizedEnv(): ActionEnvVar[] {
|
||||
return draftEnv
|
||||
.map((row) => ({
|
||||
name: row.name.trim(),
|
||||
value: row.value,
|
||||
}))
|
||||
.filter((row) => row.name !== '' || row.value.trim() !== '')
|
||||
}
|
||||
|
||||
async function handleSaveAction() {
|
||||
if (!editorKind) {
|
||||
return
|
||||
}
|
||||
setSubmitError(null)
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const env = normalizedEnv()
|
||||
const response = isEditing
|
||||
? await patchAction(editingActionId!, {
|
||||
name: draftName,
|
||||
description: draftDescription,
|
||||
body: draftBody,
|
||||
env,
|
||||
})
|
||||
: await createAction({
|
||||
name: draftName,
|
||||
description: draftDescription,
|
||||
kind: editorKind,
|
||||
body: draftBody,
|
||||
env,
|
||||
})
|
||||
setActions([...response.actions])
|
||||
clearEditor()
|
||||
} catch (err) {
|
||||
setSubmitError(
|
||||
err instanceof Error ? err.message : 'Failed to save action',
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAction(action: Action) {
|
||||
if (action.builtin || !canDeleteActions) {
|
||||
return
|
||||
}
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const response = await deleteAction(action.id)
|
||||
setActions([...response.actions])
|
||||
if (editingActionId === action.id) {
|
||||
clearEditor()
|
||||
}
|
||||
} catch (err) {
|
||||
setSubmitError(
|
||||
err instanceof Error ? err.message : 'Failed to delete action',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="actions-panel">
|
||||
<p className="config-hint">
|
||||
Pre-defined and custom actions for managed nodes. Placeholders and ENV
|
||||
vars are expanded when an action runs.
|
||||
</p>
|
||||
|
||||
{submitError && !showEditor ? (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="actions-layout">
|
||||
<div className="actions-list">
|
||||
<div className="actions-list-header">
|
||||
<p className="config-hint">Configured actions</p>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
disabled={!canCreateActions}
|
||||
title={
|
||||
canCreateActions ? undefined : 'Requires actions.create'
|
||||
}
|
||||
onClick={() => openTypePicker()}
|
||||
>
|
||||
Add Action…
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p className="config-empty">Loading…</p>
|
||||
) : loadError ? (
|
||||
<p className="config-empty" role="alert">
|
||||
{loadError}
|
||||
</p>
|
||||
) : actions.length === 0 ? (
|
||||
<p className="config-empty">No actions configured yet.</p>
|
||||
) : (
|
||||
<ul className="groups-ul">
|
||||
{actions.map((action) => {
|
||||
const isSelected = editingActionId === action.id
|
||||
return (
|
||||
<li
|
||||
key={action.id}
|
||||
className={
|
||||
isSelected ? 'groups-li groups-li-selected' : 'groups-li'
|
||||
}
|
||||
>
|
||||
<div className="groups-li-content">
|
||||
<div className="groups-li-title">
|
||||
{action.name}
|
||||
<span className="user-badge">{action.kind}</span>
|
||||
{action.builtin ? (
|
||||
<span className="user-badge">built-in</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
{action.description || 'No description'}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
<code className="actions-body-preview">
|
||||
{action.body.length > 80
|
||||
? `${action.body.slice(0, 80)}…`
|
||||
: action.body}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="groups-li-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="groups-edit"
|
||||
disabled={action.builtin || !canUpdateActions}
|
||||
title={
|
||||
action.builtin
|
||||
? 'Built-in actions cannot be edited'
|
||||
: canUpdateActions
|
||||
? undefined
|
||||
: 'Requires actions.update'
|
||||
}
|
||||
onClick={() => startEdit(action)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
disabled={action.builtin || !canDeleteActions}
|
||||
title={
|
||||
action.builtin
|
||||
? 'Built-in actions cannot be deleted'
|
||||
: canDeleteActions
|
||||
? undefined
|
||||
: 'Requires actions.delete'
|
||||
}
|
||||
onClick={() => void handleDeleteAction(action)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="actions-placeholders">
|
||||
<p className="config-hint">Placeholders</p>
|
||||
<p className="config-hint actions-placeholders-intro">
|
||||
Use these tokens in the command/script body or ENV values. ENV vars
|
||||
are applied before the action runs.
|
||||
</p>
|
||||
<ul className="actions-placeholder-list">
|
||||
{ACTION_PLACEHOLDERS.map((entry) => (
|
||||
<li key={entry.token}>
|
||||
<code>{entry.token}</code>
|
||||
<span>{entry.description}</span>
|
||||
<span className="actions-placeholder-example">
|
||||
Example: {entry.example}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{showTypePicker ? (
|
||||
<div className="reauth-backdrop" role="presentation">
|
||||
<div
|
||||
className="reauth-modal actions-type-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="action-type-title"
|
||||
>
|
||||
<h2 id="action-type-title">Add Action</h2>
|
||||
<p className="config-hint">Choose the type of action to create.</p>
|
||||
<div className="actions-type-choices">
|
||||
<button
|
||||
type="button"
|
||||
className="actions-type-choice"
|
||||
onClick={() => startCreate('shell')}
|
||||
>
|
||||
<strong>Shell Command</strong>
|
||||
<span>
|
||||
A single command run over SSH on the target node (e.g.{' '}
|
||||
<code>uptime</code>, <code>df -h</code>).
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="actions-type-choice"
|
||||
onClick={() => startCreate('script')}
|
||||
>
|
||||
<strong>Script</strong>
|
||||
<span>
|
||||
A multi-line shell script uploaded and run on the target node.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="reauth-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => clearEditor()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showEditor && editorKind ? (
|
||||
<div className="reauth-backdrop" role="presentation">
|
||||
<div
|
||||
className="reauth-modal actions-editor-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="action-editor-title"
|
||||
>
|
||||
<h2 id="action-editor-title">
|
||||
{isEditing
|
||||
? `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
|
||||
: `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}
|
||||
</h2>
|
||||
|
||||
{submitError ? (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="action-name">Name</label>
|
||||
<input
|
||||
id="action-name"
|
||||
className="config-input"
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="action-description">Description</label>
|
||||
<input
|
||||
id="action-description"
|
||||
className="config-input"
|
||||
value={draftDescription}
|
||||
onChange={(event) => setDraftDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="action-body">
|
||||
{editorKind === 'shell' ? 'Command' : 'Script'}
|
||||
</label>
|
||||
{editorKind === 'shell' ? (
|
||||
<input
|
||||
id="action-body"
|
||||
className="config-input"
|
||||
value={draftBody}
|
||||
placeholder="echo {{node.ip}}"
|
||||
onChange={(event) => setDraftBody(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
id="action-body"
|
||||
className="config-input actions-script-input"
|
||||
value={draftBody}
|
||||
rows={8}
|
||||
placeholder={"#!/bin/sh\necho {{node.host}}"}
|
||||
onChange={(event) => setDraftBody(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label>Environment variables</label>
|
||||
<p className="config-hint">
|
||||
Applied before the command/script runs. Values may use
|
||||
placeholders.
|
||||
</p>
|
||||
<div className="actions-env-rows">
|
||||
{draftEnv.map((row, index) => (
|
||||
<div key={`env-${index}`} className="actions-env-row">
|
||||
<input
|
||||
className="config-input"
|
||||
aria-label={`ENV name ${index + 1}`}
|
||||
placeholder="NAME"
|
||||
value={row.name}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, 'name', event.target.value)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="config-input"
|
||||
aria-label={`ENV value ${index + 1}`}
|
||||
placeholder="value or {{node.ip}}"
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, 'value', event.target.value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
aria-label={`Remove ENV row ${index + 1}`}
|
||||
onClick={() => removeEnvRow(index)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-new"
|
||||
onClick={() => addEnvRow()}
|
||||
>
|
||||
Add ENV variable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="reauth-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => clearEditor()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-save"
|
||||
disabled={isSaving}
|
||||
onClick={() => void handleSaveAction()}
|
||||
>
|
||||
{isSaving ? 'Saving…' : isEditing ? 'Save changes' : 'Save action'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupsConfigTab() {
|
||||
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
|
||||
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
|
||||
@@ -1740,6 +2287,10 @@ function ConfigTabPanelContent({
|
||||
return <GroupsConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'actions') {
|
||||
return <ActionsConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'users') {
|
||||
return <UsersConfigTab />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createAction,
|
||||
deleteAction,
|
||||
deleteGroup,
|
||||
deleteNode,
|
||||
deleteUser,
|
||||
fetchActions,
|
||||
fetchGroups,
|
||||
fetchHealth,
|
||||
fetchNetwork,
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
getApiBaseUrl,
|
||||
patchAction,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
@@ -622,3 +626,93 @@ describe('nodes API', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions API', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('fetches actions', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
actions: [
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'Uptime',
|
||||
description: 'uptime',
|
||||
kind: 'shell',
|
||||
body: 'uptime',
|
||||
env: [],
|
||||
builtin: true,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await fetchActions(fetchMock)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/actions',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.actions).toHaveLength(1)
|
||||
expect(payload.actions[0]?.name).toBe('Uptime')
|
||||
})
|
||||
|
||||
it('creates an action', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ actions: [] }),
|
||||
})
|
||||
|
||||
await createAction(
|
||||
{
|
||||
name: 'Echo',
|
||||
description: 'echo ip',
|
||||
kind: 'shell',
|
||||
body: 'echo {{node.ip}}',
|
||||
env: [{ name: 'APP_HOME', value: '/opt/app' }],
|
||||
},
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/actions',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: 'Echo',
|
||||
description: 'echo ip',
|
||||
kind: 'shell',
|
||||
body: 'echo {{node.ip}}',
|
||||
env: [{ name: 'APP_HOME', value: '/opt/app' }],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('patches and deletes an action', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ actions: [] }),
|
||||
})
|
||||
|
||||
await patchAction('custom-1', { name: 'Renamed', body: 'echo hi' }, fetchMock)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/actions?id=custom-1',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name: 'Renamed', body: 'echo hi' }),
|
||||
}),
|
||||
)
|
||||
|
||||
await deleteAction('custom-1', fetchMock)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/actions?id=custom-1',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -760,3 +760,103 @@ export async function fetchNodeLogs(
|
||||
}
|
||||
return (await response.json()) as NodeLogsResponse
|
||||
}
|
||||
|
||||
export type ActionKind = 'shell' | 'script'
|
||||
|
||||
export type ActionEnvVar = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type Action = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
kind: ActionKind
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
builtin: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ActionsResponse = {
|
||||
actions: ReadonlyArray<Action>
|
||||
}
|
||||
|
||||
export type CreateActionRequest = {
|
||||
name: string
|
||||
description: string
|
||||
kind: ActionKind
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export type PatchActionRequest = {
|
||||
name?: string
|
||||
description?: string
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export async function fetchActions(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionsResponse> {
|
||||
const response = await apiFetch('/api/v1/actions', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionsResponse
|
||||
}
|
||||
|
||||
export async function createAction(
|
||||
action: CreateActionRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionsResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/actions',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(action),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionsResponse
|
||||
}
|
||||
|
||||
export async function patchAction(
|
||||
id: string,
|
||||
patch: PatchActionRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/actions?id=${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionsResponse
|
||||
}
|
||||
|
||||
export async function deleteAction(
|
||||
id: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/actions?id=${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionsResponse
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('pathFor', () => {
|
||||
expect(pathFor('configuration')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||
expect(pathFor('configuration', 'actions')).toBe('/configuration/actions')
|
||||
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
||||
})
|
||||
})
|
||||
@@ -39,6 +40,7 @@ describe('parseLocation', () => {
|
||||
'/configuration/users',
|
||||
'/configuration/roles',
|
||||
'/configuration/groups',
|
||||
'/configuration/actions',
|
||||
'/configuration/security',
|
||||
'/configuration/integrations',
|
||||
'/configuration/network',
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ConfigTabId =
|
||||
| 'users'
|
||||
| 'roles'
|
||||
| 'groups'
|
||||
| 'actions'
|
||||
| 'security'
|
||||
| 'integrations'
|
||||
| 'network'
|
||||
@@ -34,6 +35,7 @@ const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'users',
|
||||
'roles',
|
||||
'groups',
|
||||
'actions',
|
||||
'security',
|
||||
'integrations',
|
||||
'network',
|
||||
|
||||
Reference in New Issue
Block a user