feat: Show exec conditions in the UI, and allow right clicking buttons for action details (#1034)

This commit is contained in:
James Read 2026-05-25 21:44:28 +01:00 committed by GitHub
commit 2327197034
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 965 additions and 469 deletions

View File

@ -14,6 +14,10 @@ logLevel: "INFO"
#
# Docs: https://docs.olivetin.app/action_execution/create_your_first.html
actions:
# Every action can still be run on demand from the web UI or API. The keys
# below are optional *additional* triggers (see each action and
# https://docs.olivetin.app/action_execution/ ).
#
# This is the most simple action, it just runs the command and flashes the
# button to indicate status.
#
@ -23,6 +27,8 @@ actions:
shell: ping -c 3 1.1.1.1
icon: ping
popupOnStart: execution-dialog-stdout-only
# https://docs.olivetin.app/action_execution/onstartup.html
execOnStartup: true
# This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
# the command output.
@ -30,6 +36,10 @@ actions:
icon: disk
shell: df -h /media
popupOnStart: execution-dialog-stdout-only
# https://docs.olivetin.app/action_execution/onfilechanged.html
# Create the directory first, e.g. mkdir -p /tmp/olivetin-demo-file-changed
execOnFileChangedInDir:
- /tmp/olivetin-demo-file-changed
# This uses `popupOnStart: execution-dialog` to show a dialog with more
# information about the command that was run.
@ -37,6 +47,10 @@ actions:
shell: dmesg | tail
icon: logs
popupOnStart: execution-dialog
# https://docs.olivetin.app/action_execution/oncron.html — second example;
# the "date" action uses @hourly elsewhere in this file.
execOnCron:
- "0 3 * * 0"
# This uses `popupOnStart: execution-button` to display a mini button that
# links to the logs.
@ -51,6 +65,8 @@ actions:
maxRate:
- limit: 3
duration: 1m
execOnCron:
- "@hourly"
# You are not limited to operating system commands, and of course you can run
# your own scripts. Here `maxConcurrent` stops the script running multiple
@ -63,6 +79,8 @@ actions:
timeout: 10
icon: backup
popupOnStart: execution-dialog
# https://docs.olivetin.app/action_execution/oncalendar.html
execOnCalendarFile: examples/demo-olivetin-calendar.yaml
# When you want to prompt users for input, that is when you should use
# `arguments` - this presents a popup dialog and asks for argument values.
@ -74,6 +92,11 @@ actions:
icon: ping
timeout: 100
popupOnStart: execution-dialog-stdout-only
# https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
# with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
execOnWebhook:
- matchHeaders:
X-OliveTin-Demo: ping-host
arguments:
- name: host
title: Host
@ -149,6 +172,10 @@ actions:
icon: ssh
shell: olivetin-setup-easy-ssh
popupOnStart: execution-dialog
# Second webhook example: POST /webhooks?demo=setup-ssh
execOnWebhook:
- matchQuery:
demo: setup-ssh
# Here's how to use SSH with the "easy" config, to restart a service on
# another server.
@ -215,6 +242,10 @@ actions:
- title: Ping All Servers
shell: "echo 'Ping all servers'"
icon: ping
# https://docs.olivetin.app/action_execution/onfilecreated.html
# mkdir -p /tmp/olivetin-demo-file-created
execOnFileCreatedInDir:
- /tmp/olivetin-demo-file-created
- title: Start {{ .CurrentEntity.Names }}
icon: box

View File

@ -1,4 +1,4 @@
// @generated by protoc-gen-es v2.11.0
// @generated by protoc-gen-es v2.12.0
// @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
/* eslint-disable */
@ -60,6 +60,36 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
* @generated from field: string datetime_rate_limit_expires = 9;
*/
datetimeRateLimitExpires: string;
/**
* @generated from field: bool exec_on_startup = 10;
*/
execOnStartup: boolean;
/**
* @generated from field: repeated string exec_on_cron = 11;
*/
execOnCron: string[];
/**
* @generated from field: repeated string exec_on_file_created_in_dir = 12;
*/
execOnFileCreatedInDir: string[];
/**
* @generated from field: repeated string exec_on_file_changed_in_dir = 13;
*/
execOnFileChangedInDir: string[];
/**
* @generated from field: string exec_on_calendar_file = 14;
*/
execOnCalendarFile: string;
/**
* @generated from field: repeated olivetin.api.v1.ActionWebhookExecHint exec_on_webhooks = 15;
*/
execOnWebhooks: ActionWebhookExecHint[];
};
/**
@ -68,6 +98,27 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
*/
export declare const ActionSchema: GenMessage<Action>;
/**
* @generated from message olivetin.api.v1.ActionWebhookExecHint
*/
export declare type ActionWebhookExecHint = Message<"olivetin.api.v1.ActionWebhookExecHint"> & {
/**
* @generated from field: string template = 1;
*/
template: string;
/**
* @generated from field: string match_path = 2;
*/
matchPath: string;
};
/**
* Describes the message olivetin.api.v1.ActionWebhookExecHint.
* Use `create(ActionWebhookExecHintSchema)` to create a new message.
*/
export declare const ActionWebhookExecHintSchema: GenMessage<ActionWebhookExecHint>;
/**
* @generated from message olivetin.api.v1.ActionArgument
*/
@ -188,7 +239,7 @@ export declare type GetDashboardResponse = Message<"olivetin.api.v1.GetDashboard
/**
* @generated from field: olivetin.api.v1.Dashboard dashboard = 4;
*/
dashboard?: Dashboard;
dashboard?: Dashboard | undefined;
};
/**
@ -302,7 +353,7 @@ export declare type DashboardComponent = Message<"olivetin.api.v1.DashboardCompo
/**
* @generated from field: olivetin.api.v1.Action action = 6;
*/
action?: Action;
action?: Action | undefined;
/**
* @generated from field: string entity_type = 7;
@ -412,7 +463,7 @@ export declare type StartActionAndWaitResponse = Message<"olivetin.api.v1.StartA
/**
* @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
*/
logEntry?: LogEntry;
logEntry?: LogEntry | undefined;
};
/**
@ -476,7 +527,7 @@ export declare type StartActionByGetAndWaitResponse = Message<"olivetin.api.v1.S
/**
* @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
*/
logEntry?: LogEntry;
logEntry?: LogEntry | undefined;
};
/**
@ -825,7 +876,7 @@ export declare type ExecutionStatusResponse = Message<"olivetin.api.v1.Execution
/**
* @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
*/
logEntry?: LogEntry;
logEntry?: LogEntry | undefined;
};
/**
@ -1135,7 +1186,7 @@ export declare type EventExecutionFinished = Message<"olivetin.api.v1.EventExecu
/**
* @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
*/
logEntry?: LogEntry;
logEntry?: LogEntry | undefined;
};
/**
@ -1151,7 +1202,7 @@ export declare type EventExecutionStarted = Message<"olivetin.api.v1.EventExecut
/**
* @generated from field: olivetin.api.v1.LogEntry log_entry = 1;
*/
logEntry?: LogEntry;
logEntry?: LogEntry | undefined;
};
/**
@ -1437,7 +1488,7 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
/**
* @generated from field: olivetin.api.v1.EffectivePolicy effective_policy = 18;
*/
effectivePolicy?: EffectivePolicy;
effectivePolicy?: EffectivePolicy | undefined;
/**
* @generated from field: string banner_message = 19;
@ -1553,7 +1604,7 @@ export declare type GetActionBindingResponse = Message<"olivetin.api.v1.GetActio
/**
* @generated from field: olivetin.api.v1.Action action = 1;
*/
action?: Action;
action?: Action | undefined;
};
/**
@ -1858,3 +1909,4 @@ export declare const OliveTinApiService: GenService<{
output: typeof EntitySchema;
},
}>;

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
<template>
<div :id="`actionButton-${bindingId}`" role="none" class="action-button">
<div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
:class="combinedClasses" @click="handleClick">
@ -191,7 +191,19 @@ function updateRateLimitStatus() {
}
}
function openActionDetails() {
const id = props.actionData?.bindingId
if (!id) {
return
}
router.push(`/action/${id}`)
}
async function handleClick() {
if (popupOnStart.value === 'history') {
openActionDetails()
return
}
if (props.actionData.arguments && props.actionData.arguments.length > 0) {
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
} else {
@ -249,8 +261,6 @@ function onLogEntryChanged(logEntry) {
function onExecutionStarted(logEntry) {
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
router.push(`/logs/${logEntry.executionTrackingId}`)
} else if (popupOnStart.value === 'history') {
router.push(`/action/${bindingId.value}`)
}
isDisabled.value = true

View File

@ -95,6 +95,19 @@ const routes = [
]
}
},
{
path: '/action/:actionId/actionexecconditions',
name: 'ActionExecConditions',
component: () => import('./views/ActionExecConditionsView.vue'),
props: true,
meta: {
title: 'Execution conditions',
breadcrumb: [
{ name: "Actions", href: "/" },
{ name: "Execution conditions" },
]
}
},
{
path: '/diagnostics',
name: 'Diagnostics',

View File

@ -1,12 +1,22 @@
<template>
<Section :title="'Action Details: ' + actionTitle" :padding="false">
<template #toolbar>
<button v-if="action" @click="startAction" title="Start this action" class="button neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 6v12l8-6z" />
</svg>
Start
</button>
<div class="action-details-toolbar">
<button v-if="action" @click="startAction" title="Start this action" class="button neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 6v12l8-6z" />
</svg>
Start
</button>
<router-link
v-if="action"
:to="{ name: 'ActionExecConditions', params: { actionId: route.params.actionId } }"
class="button neutral"
title="View configured automatic triggers and on-demand execution"
>
Execution conditions
</router-link>
</div>
</template>
<div class = "flex-row padding" v-if="action">
@ -428,5 +438,12 @@ watch(
.padding {
padding: 1rem;
}
.action-details-toolbar {
display: inline-flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
</style>

View File

@ -0,0 +1,209 @@
<template>
<Section :title="'Execution conditions: ' + actionTitle" :padding="false">
<template #toolbar>
<router-link :to="{ name: 'ActionDetails', params: { actionId: route.params.actionId } }" class="button neutral">
Back to action details
</router-link>
</template>
<div v-if="action" class="padding content">
<p>
These entries mirror the automatic triggers from your OliveTin configuration for this action.
You can always run the action manually as well.
</p>
<h3 class="exec-type-heading">
On demand
<a class="doc-link" :href="execConditionDocs.onDemand" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<p>
Manual execution from the web UI (dashboard or action details), or via the API (for example StartAction),
is always available when your user is allowed to execute the action.
</p>
<template v-if="action.execOnStartup">
<h3 class="exec-type-heading">
<code>execOnStartup</code>
<a class="doc-link" :href="execConditionDocs.startup" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<p>Runs once when OliveTin starts.</p>
</template>
<template v-if="nonEmptyList(action.execOnCron)">
<h3 class="exec-type-heading">
<code>execOnCron</code>
<a class="doc-link" :href="execConditionDocs.cron" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<ul>
<li v-for="(line, idx) in action.execOnCron" :key="'cron-' + idx"><code>{{ line }}</code></li>
</ul>
</template>
<template v-if="nonEmptyList(action.execOnFileCreatedInDir)">
<h3 class="exec-type-heading">
<code>execOnFileCreatedInDir</code>
<a class="doc-link" :href="execConditionDocs.fileCreated" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<ul>
<li v-for="(dir, idx) in action.execOnFileCreatedInDir" :key="'created-' + idx"><code>{{ dir }}</code></li>
</ul>
</template>
<template v-if="nonEmptyList(action.execOnFileChangedInDir)">
<h3 class="exec-type-heading">
<code>execOnFileChangedInDir</code>
<a class="doc-link" :href="execConditionDocs.fileChanged" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<ul>
<li v-for="(dir, idx) in action.execOnFileChangedInDir" :key="'changed-' + idx"><code>{{ dir }}</code></li>
</ul>
</template>
<template v-if="action.execOnCalendarFile">
<h3 class="exec-type-heading">
<code>execOnCalendarFile</code>
<a class="doc-link" :href="execConditionDocs.calendar" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<p><code>{{ action.execOnCalendarFile }}</code></p>
</template>
<template v-if="nonEmptyList(action.execOnWebhooks)">
<h3 class="exec-type-heading">
<code>execOnWebhook</code>
<a class="doc-link" :href="execConditionDocs.webhook" target="_blank" rel="noopener noreferrer">Documentation</a>
</h3>
<ul class="webhook-list">
<li v-for="(wh, idx) in action.execOnWebhooks" :key="'wh-' + idx">
<span v-if="wh.template">template: <code>{{ wh.template }}</code></span>
<span v-if="wh.matchPath"> · matchPath: <code>{{ wh.matchPath }}</code></span>
<span v-if="!wh.template && !wh.matchPath">Webhook trigger (no template or match path in response)</span>
</li>
</ul>
</template>
<p v-if="!hasConfiguredTriggers" class="muted">
This action has no automatic triggers in configuration besides on-demand execution.
</p>
</div>
<div v-else-if="!loading" class="padding empty-state">
<p>Could not load this action.</p>
<router-link :to="{ name: 'Actions' }">Return to index</router-link>
</div>
</Section>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import Section from 'picocrank/vue/components/Section.vue'
const route = useRoute()
const action = ref(null)
const actionTitle = ref('Action')
const loading = ref(true)
const execConditionDocs = {
onDemand: 'https://docs.olivetin.app/action_execution/ondemand.html',
startup: 'https://docs.olivetin.app/action_execution/onstartup.html',
cron: 'https://docs.olivetin.app/action_execution/oncron.html',
fileCreated: 'https://docs.olivetin.app/action_execution/onfilecreated.html',
fileChanged: 'https://docs.olivetin.app/action_execution/onfilechanged.html',
calendar: 'https://docs.olivetin.app/action_execution/oncalendar.html',
webhook: 'https://docs.olivetin.app/action_execution/onwebhook.html',
}
function nonEmptyList(list) {
return Array.isArray(list) && list.length > 0
}
const hasConfiguredTriggers = computed(() => {
const a = action.value
if (!a) {
return false
}
if (a.execOnStartup) {
return true
}
if (nonEmptyList(a.execOnCron) || nonEmptyList(a.execOnFileCreatedInDir) || nonEmptyList(a.execOnFileChangedInDir)) {
return true
}
if (a.execOnCalendarFile) {
return true
}
if (nonEmptyList(a.execOnWebhooks)) {
return true
}
return false
})
async function fetchAction() {
loading.value = true
try {
const actionId = route.params.actionId
const response = await window.client.getActionBinding({ bindingId: actionId })
action.value = response.action
actionTitle.value = response.action?.title || 'Action'
} catch (err) {
console.error('Failed to fetch action:', err)
window.showBigError('fetch-action-exec-conditions', 'getting action', err, false)
action.value = null
} finally {
loading.value = false
}
}
onMounted(fetchAction)
watch(
() => route.params.actionId,
() => {
action.value = null
actionTitle.value = 'Action'
fetchAction()
}
)
</script>
<style scoped>
.content h3 {
margin-top: 1.25rem;
margin-bottom: 0.35rem;
font-size: 1rem;
}
.exec-type-heading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.75rem;
}
.exec-type-heading .doc-link {
font-size: 0.85rem;
font-weight: normal;
}
.content p,
.content ul {
margin: 0.35rem 0 0;
}
.webhook-list li {
margin-bottom: 0.35rem;
}
.muted {
color: var(--text-secondary);
margin-top: 1.5rem;
}
.empty-state {
text-align: center;
color: var(--text-secondary);
}
.padding {
padding: 1rem;
}
</style>

View File

@ -392,6 +392,11 @@ async function startAction(actionArgs) {
async function handleSubmit(event) {
event.preventDefault()
if (popupOnStart.value === 'history') {
router.push(`/action/${props.bindingId}`)
return
}
// Set custom validity for required fields
for (const arg of actionArguments.value) {
const value = argValues.value[arg.name]
@ -422,8 +427,6 @@ async function handleSubmit(event) {
const response = await startAction(argvs)
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
router.push(`/logs/${response.executionTrackingId}`)
} else if (popupOnStart.value === 'history') {
router.push(`/action/${props.bindingId}`)
} else {
router.back()
}

View File

@ -14,6 +14,17 @@ message Action {
int32 order = 7;
int32 timeout = 8;
string datetime_rate_limit_expires = 9; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
bool exec_on_startup = 10;
repeated string exec_on_cron = 11;
repeated string exec_on_file_created_in_dir = 12;
repeated string exec_on_file_changed_in_dir = 13;
string exec_on_calendar_file = 14;
repeated ActionWebhookExecHint exec_on_webhooks = 15;
}
message ActionWebhookExecHint {
string template = 1;
string match_path = 2;
}
message ActionArgument {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
package api
import (
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
)
func applyActionExecTriggers(pb *apiv1.Action, cfg *config.Action) {
if cfg == nil {
return
}
pb.ExecOnStartup = cfg.ExecOnStartup
pb.ExecOnCron = append([]string(nil), cfg.ExecOnCron...)
pb.ExecOnFileCreatedInDir = append([]string(nil), cfg.ExecOnFileCreatedInDir...)
pb.ExecOnFileChangedInDir = append([]string(nil), cfg.ExecOnFileChangedInDir...)
pb.ExecOnCalendarFile = cfg.ExecOnCalendarFile
for _, wh := range cfg.ExecOnWebhook {
pb.ExecOnWebhooks = append(pb.ExecOnWebhooks, &apiv1.ActionWebhookExecHint{
Template: wh.Template,
MatchPath: wh.MatchPath,
})
}
}

View File

@ -156,6 +156,8 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
DatetimeRateLimitExpires: datetimeRateLimitExpires,
}
applyActionExecTriggers(&btn, action)
for _, cfgArg := range action.Arguments {
pbArg := apiv1.ActionArgument{
Name: cfgArg.Name,

View File

@ -13,6 +13,13 @@ import (
func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
for _, action := range cfg.Actions {
for _, dirname := range action.ExecOnFileCreatedInDir {
go func(act *config.Action, dir string) {
filehelper.WatchDirectoryCreate(dir, func(filename string) {
scheduleExec(act, cfg, ex, filename)
})
}(action, dirname)
}
for _, dirname := range action.ExecOnFileChangedInDir {
// Pass values into anonymous function because of this issue
// https://github.com/OliveTin/OliveTin/issues/503
@ -22,12 +29,6 @@ func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
scheduleExec(act, cfg, ex, filename)
})
}(action, dirname)
go func(act *config.Action, dir string) {
filehelper.WatchDirectoryCreate(dir, func(filename string) {
scheduleExec(act, cfg, ex, filename)
})
}(action, dirname)
}
}
}