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:
2026-07-18 19:18:13 +02:00
parent 77be394407
commit c90a47c3ea
17 changed files with 1770 additions and 9 deletions
+551
View File
@@ -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 />
}