Prechádzať zdrojové kódy

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

jamesread 1 mesiac pred
rodič
commit
cbed6d68c2

+ 31 - 0
config.yaml

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

+ 62 - 10
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -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;
   },
 }>;
+

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 13 - 3
frontend/resources/vue/ActionButton.vue

@@ -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') {
+	router.push(`/action/${props.actionData.bindingId}`)
+	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

+ 13 - 0
frontend/resources/vue/router.js

@@ -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',

+ 23 - 6
frontend/resources/vue/views/ActionDetailsView.vue

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

+ 209 - 0
frontend/resources/vue/views/ActionExecConditionsView.vue

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

+ 5 - 2
frontend/resources/vue/views/ArgumentForm.vue

@@ -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()
     }

+ 11 - 0
proto/olivetin/api/v1/olivetin.proto

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

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 207 - 107
service/gen/olivetin/api/v1/olivetin.pb.go


+ 25 - 0
service/internal/api/apiActionExecTriggers.go

@@ -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,
+		})
+	}
+}

+ 2 - 0
service/internal/api/apiActions.go

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

+ 7 - 0
service/internal/onfileindir/fileindir.go

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

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov