Ver código fonte

feat: circle indicator for running actions, justification support

jamesread 2 semanas atrás
pai
commit
de63793b3a
45 arquivos alterados com 1693 adições e 324 exclusões
  1. 1 1
      .github/workflows/docs-antora.yml
  2. 8 0
      AGENTS.md
  3. 41 46
      config.yaml
  4. 1 1
      docs/modules/ROOT/nav.adoc
  5. 1 2
      docs/modules/ROOT/pages/action_customization/intro.adoc
  6. 14 15
      docs/modules/ROOT/pages/action_customization/popuponstart.adoc
  7. 1 1
      docs/modules/ROOT/pages/action_examples/ssh-easy.adoc
  8. 6 6
      docs/modules/ROOT/pages/advanced_configuration/webui.adoc
  9. 2 1
      docs/modules/ROOT/pages/config.adoc
  10. 3 4
      docs/modules/ROOT/pages/solutions/k8s-control-panel-hosted/index.adoc
  11. 22 10
      frontend/js/websocket.js
  12. 30 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  13. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  14. 76 3
      frontend/resources/vue/ActionButton.vue
  15. 31 7
      frontend/resources/vue/components/ActionStatusDisplay.vue
  16. 27 0
      frontend/resources/vue/components/LogActionTitle.vue
  17. 79 0
      frontend/resources/vue/stores/bindingExecutionState.js
  18. 54 0
      frontend/resources/vue/utils/executionLogEvents.js
  19. 3 0
      frontend/resources/vue/utils/needsArgumentForm.js
  20. 4 0
      frontend/resources/vue/utils/popupOnStartNavigation.js
  21. 35 11
      frontend/resources/vue/views/ActionDetailsView.vue
  22. 49 6
      frontend/resources/vue/views/ArgumentForm.vue
  23. 17 4
      frontend/resources/vue/views/ExecutionView.vue
  24. 26 2
      frontend/resources/vue/views/LogsListView.vue
  25. 240 98
      frontend/resources/vue/views/LogsQueueView.vue
  26. 5 3
      integration-tests/lib/elements.js
  27. 1 1
      integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js
  28. 1 1
      lang/combined_output.json
  29. 7 0
      proto/olivetin/api/v1/olivetin.proto
  30. 62 8
      service/gen/olivetin/api/v1/olivetin.pb.go
  31. 50 48
      service/internal/api/api.go
  32. 84 27
      service/internal/api/apiActions.go
  33. 92 0
      service/internal/api/api_actions_active_test.go
  34. 45 0
      service/internal/api/api_justification.go
  35. 89 0
      service/internal/api/api_justification_test.go
  36. 13 8
      service/internal/config/config.go
  37. 39 3
      service/internal/config/sanitize.go
  38. 61 1
      service/internal/config/sanitize_test.go
  39. 17 4
      service/internal/executor/executor.go
  40. 97 0
      service/internal/executor/group_concurrency_test.go
  41. 68 0
      service/internal/executor/justification.go
  42. 130 0
      service/internal/executor/justification_test.go
  43. 12 2
      service/internal/webhooks/handler.go
  44. 13 0
      service/internal/webhooks/matcher.go
  45. 36 0
      service/internal/webhooks/matcher_justification_test.go

+ 1 - 1
.github/workflows/docs-antora.yml

@@ -25,7 +25,7 @@ jobs:
       - name: Install Node.js
         uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '22'
 
       - name: Install Antora toolchain
         run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6

+ 8 - 0
AGENTS.md

@@ -66,6 +66,14 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 - Review the pull request template at `.github/PULL_REQUEST_TEMPLATE.md`.
 - When changing behaviour covered by a spec in `specs/`, ensure implementation and tests match the spec.
 
+### Branch Naming
+Use conventional-commit-style branch names with a type prefix, optional issue reference, and a short kebab-case description:
+
+- `feat/[#123]-add-justification-prompt`
+- `fix/[#456]-websocket-reconnect`
+
+Do **not** use `feat-...`, `feature/...`, or other variants. Omit the `[#<issue>]` segment only when there is no linked issue.
+
 ### Troubleshooting
 - API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
 - Executor panics: check for nil `Binding/Action` and add guards in step functions.

+ 41 - 46
config.yaml

@@ -22,6 +22,27 @@ actionGroups:
 #
 # Docs: https://docs.olivetin.app/action_execution/create_your_first.html
 actions:
+  # Lots of people use OliveTin to build web interfaces for their electronics
+  # projects. It's best to install OliveTin as a native package (eg, .deb), and
+  # then you can use either a python script or the `gpio` command.
+  - title: Toggle GPIO light
+    shell: gpioset gpiochip1 9=1 || true # The "|| true" is to ignore errors the demo environment doesn't have GPIO access.
+    icon: light
+
+  # Lots of people also use OliveTin to monitor their servers, like checking
+  # disk space, or checking logs. `onclick: execution-dialog` shows output.
+  - title: Check disk space
+    icon: disk
+    shell: df -h /
+    onclick: execution-dialog
+
+  # This uses `onclick: execution-dialog` to show a dialog with more
+  # information about the command that was run.
+  - title: Check shell history
+    shell: cat ~/.bash_history
+    icon: logs
+    onclick: execution-dialog
+
   # 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/ ).
@@ -34,47 +55,19 @@ actions:
   - title: Ping the Internet
     shell: ping -c 3 1.1.1.1
     icon: ping
-    popupOnStart: execution-dialog-stdout-only
+    onclick: execution-dialog
     # https://docs.olivetin.app/action_execution/onstartup.html
     execOnStartup: true
 
-  # This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
-  # the command output.
-  - title: Check disk space
-    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.
-  - title: check dmesg logs
-    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.
-  #
   # You can also rate-limit actions too.
-  - title: date
-    shell: date
-    id: date
-    timeout: 6
-    icon: clock
-    popupOnStart: execution-button
+  - title: Sync Disks
+    shell: sync
+    id: syncdisks
+    icon: disk
+    onclick: execution-button
     maxRate:
       - limit: 3
         duration: 1m
-    execOnCron:
-      - "@hourly"
 
   # You are not limited to operating system commands, and of course you can run
   # your own scripts. The backup-jobs action group limits how many backup-related
@@ -86,7 +79,7 @@ actions:
     groups: [ backup-jobs ]
     timeout: 10
     icon: backup
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
     # https://docs.olivetin.app/action_execution/oncalendar.html
     execOnCalendarFile: examples/demo-olivetin-calendar.yaml
 
@@ -95,7 +88,7 @@ actions:
     groups: [ backup-jobs ]
     timeout: 30
     icon: backup
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
 
   # 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.
@@ -106,7 +99,7 @@ actions:
     shell: ping {{ host }} -c {{ count }}
     icon: ping
     timeout: 100
-    popupOnStart: execution-dialog-stdout-only
+    onclick: history
     # 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:
@@ -148,7 +141,8 @@ actions:
   # Docs: https://docs.olivetin.app/args/input_confirmation.html
   - title: Delete old backups
     icon: ashtonished
-    shell: rm -rf /opt/oldBackups/
+    justification: true
+    shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
     arguments:
       - type: html
         title: Description
@@ -186,7 +180,7 @@ actions:
   - title: "Setup easy SSH"
     icon: ssh
     shell: olivetin-setup-easy-ssh
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
     # Second webhook example: POST /webhooks?demo=setup-ssh
     execOnWebhook:
       - matchQuery:
@@ -203,13 +197,6 @@ actions:
     timeout: 1
     shell: ssh -F /config/ssh/easy.cfg root@server1 'service httpd restart'
 
-  # Lots of people use OliveTin to build web interfaces for their electronics
-  # projects. It's best to install OliveTin as a native package (eg, .deb), and
-  # then you can use either a python script or the `gpio` command.
-  - title: Toggle GPIO light
-    shell: gpioset gpiochip1 9=1
-    icon: light
-
   # There are several built-in shortcuts for the `icon` option, but you
   # can also just specify any HTML, this includes any unicode character,
   # or a <img = "..." /> link to a custom icon.
@@ -274,6 +261,14 @@ actions:
     entity: container
     triggers: ["Update container entity file"]
 
+  - title: Long running action
+    shell: sleep 300
+    timeout: 300
+    icon: logs
+    onclick: execution-dialog
+    execOnCron:
+      - "@hourly"
+
   # Lastly, you can hide actions from the web UI, this is useful for creating
   # background helpers that execute only on startup or a cron, for updating
   # entity files.

+ 1 - 1
docs/modules/ROOT/nav.adoc

@@ -48,7 +48,7 @@
 ** xref:action_execution/aftercompletion.adoc[Execute after completion]
 ** xref:action_execution/triggers.adoc[Triggers]
 * xref:action_customization/intro.adoc[Action Customization]
-** xref:action_customization/popuponstart.adoc[Popup on start]
+** xref:action_customization/popuponstart.adoc[On click]
 ** xref:action_customization/icons.adoc[Icons]
 ** xref:action_customization/timeouts.adoc[Timeouts]
 ** xref:action_customization/users.adoc[Users]

+ 1 - 2
docs/modules/ROOT/pages/action_customization/intro.adoc

@@ -27,7 +27,6 @@ Explore specific customization options:
 * xref:action_customization/concurrency.adoc[Control concurrency] - Limit simultaneous executions
 * xref:action_customization/ratelimiting.adoc[Set rate limits] - Prevent actions from running too frequently
 * xref:action_customization/enabledExpression.adoc[Enabled expressions] - Dynamically enable/disable actions based on entity state
-* xref:action_customization/popuponstart.adoc[Configure popups] - Control popup behavior when actions start
+* xref:action_customization/popuponstart.adoc[Configure on-click behavior] - Control what happens when an action is started
 * xref:action_customization/savelogs.adoc[Save action logs] - Configure log retention for actions
 * xref:action_customization/ids.adoc[Set action IDs] - Assign IDs for API access
-

+ 14 - 15
docs/modules/ROOT/pages/action_customization/popuponstart.adoc

@@ -1,10 +1,11 @@
-[#popup-on-start]
-= Popup on Start (Execution Feedback)
+[#onclick]
+= On Click (Execution Feedback)
 
-OliveTin now has several options to control "execution feedback" when actions are started. This can be controlled on
-a per-action basis, using the `popupOnStart` configuration option.
+OliveTin has several options to control what happens when an action button is clicked and the execution starts. This can be controlled on a per-action basis using the `onclick` configuration option.
 
-You can also set the default for OliveTin using the `defaultPopupOnStart` configuration option.
+You can also set the default for OliveTin using the `defaultOnClick` configuration option.
+
+NOTE: The older names `popupOnStart` and `defaultPopupOnStart` do exactly the same thing. OliveTin copies legacy values into `onclick` / `defaultOnClick` during config load. New configurations should use `onclick` and `defaultOnClick`.
 
 == Big Flashy Buttons (default)
 
@@ -13,7 +14,7 @@ You can also set the default for OliveTin using the `defaultPopupOnStart` config
 ----
 actions:
   - title: Ping the Internet
-    popupOnStart: default
+    onclick: default
 ----
 
 This will also be the option that is used if no other values match.
@@ -29,7 +30,7 @@ This can be useful for just displaying the output of a command, without too many
 ----
 actions:
   - title: Check disk space
-    popupOnStart: execution-dialog-stdout-only
+    onclick: execution-dialog-stdout-only
 ----
 
 [NOTE]
@@ -39,22 +40,22 @@ image::../popupOutputOnly.png[]
 
 == Execution Dialog
 
-The `execution-dialog` option for `popupOnStart` is simialr to the above `execution-dialog-stdout-only`, but it includes the start time, end time, exit code and the duration of time it took for the command to execute.
+The `execution-dialog` option is simialr to the above `execution-dialog-stdout-only`, but it includes the start time, end time, exit code and the duration of time it took for the command to execute.
 
 [source,yaml]
 .`config.yaml`
 ----
 actions:
   - title: Check dmesg logs
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
 ----
 
-.Example of `popupOnStart: execution-dialog`
+.Example of `onclick: execution-dialog`
 image::../executionDialog.png[]
 
 == Execution Buttons
 
-This mode of `popupOnStart` will create a new button for each individual execution. This can be useful for actions that are executed again and again.
+This mode will create a new button for each individual execution. This can be useful for actions that are executed again and again.
 
 The text of the button (eg, "0s" in the screenshot below), is the time it took to execute the action in seconds.
 
@@ -63,7 +64,7 @@ The text of the button (eg, "0s" in the screenshot below), is the time it took t
 ----
 actions:
   - title: date
-    popupOnStart: execution-button
+    onclick: execution-button
 ----
 
 image::../executionButtons.png[]
@@ -77,7 +78,5 @@ The `history` option opens the action details page for that binding when the exe
 ----
 actions:
   - title: Long-running job
-    popupOnStart: history
+    onclick: history
 ----
-
-

+ 1 - 1
docs/modules/ROOT/pages/action_examples/ssh-easy.adoc

@@ -26,7 +26,7 @@ Setup an action as follows, to use the builtin olivetin-setup-easy-ssh script th
 actions:
   - title: Setup SSH
     shell: olivetin-setup-easy-ssh
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
 ----
 
 [#ssh-easy-step-2]

+ 6 - 6
docs/modules/ROOT/pages/advanced_configuration/webui.adoc

@@ -1,7 +1,7 @@
 [#customize-webui]
 = Customize the web UI
 
-The OliveTin web UI is reasonably customizable - parts of the page that you don't need can be hidden when they're not needed. 
+The OliveTin web UI is reasonably customizable - parts of the page that you don't need can be hidden when they're not needed.
 
 == Page Title
 
@@ -43,8 +43,8 @@ image::defaultUiHideNav.png[]
 
 When enabled (the default), each action button can show a small icon indicating what happens when the action is started:
 
-* **Popup dialog** — the action opens a popup (e.g. `popupOnStart: execution-dialog`)
-* **Action history** — the action opens the action details page (e.g. `popupOnStart: history`)
+* **Popup dialog** — the action opens a popup (e.g. `onclick: execution-dialog`)
+* **Action history** — the action opens the action details page (e.g. `onclick: history`)
 * **Argument form** — the action opens an argument form on start
 * **Run in background** — the action runs without opening a dialog
 
@@ -85,7 +85,7 @@ This is controlled by the **showVersionNumber** policy (in `defaultPolicy` or pe
 [#show-new-versions]
 == New version available - show/hide
 
-You can disable the "new version" information in the footer - the default for `showNewVersions` is `true`; 
+You can disable the "new version" information in the footer - the default for `showNewVersions` is `true`;
 
 .`config.yaml`
 [source,yaml]
@@ -135,7 +135,7 @@ This is considered an advanced feature, and is not recommended unless you like w
 You can add custom JavaScript to OliveTin, which will be executed on every page load. This can be useful for adding custom functionality to the web UI.
 
 1. The custom javascript should be in a file called `custom.js` and saved in `custom-webui/`, which should be in the same directory as your `config.yaml`.
-2. You can put whatever code you like really in your `custom.js` file. 
+2. You can put whatever code you like really in your `custom.js` file.
 3. Set `enableCustomJs: true` in your `config.yaml` to enable this feature.
 4. Restart OliveTin. Note that the custom JavaScript will only be loaded once on startup, so if you are changing the custom JavaScript while OliveTin is running, you will need to restart OliveTin to see the changes.
 
@@ -147,7 +147,7 @@ You can customize OliveTin with themes, but it's also possible to write your on
 
 === Writing a simple theme with a CSS change
 
-You'll need to create a new theme, and let's assume our theme name is going to be called `uihack`. OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/<theme-folder-name>` in the same directory as your `config.yaml` file. 
+You'll need to create a new theme, and let's assume our theme name is going to be called `uihack`. OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/<theme-folder-name>` in the same directory as your `config.yaml` file.
 
 Start by creating a directory called `custom-webui/themes/uihack` relative to the same directory as your `config.yaml` file. In this directory, create a file called `theme.css`.
 

+ 2 - 1
docs/modules/ROOT/pages/config.adoc

@@ -53,7 +53,8 @@ All configuration options are covered in the solution sections
 | `showNavigation` | Show (or hide) the sidebar/topbar section navigation. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
 | `showNavigateOnStartIcons` | Show (or hide) the small icons on action buttons that indicate popup/argument/background behavior on start. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
 | `sectionNavigationStyle` | The style of the section navigation. `sidebar`, `topbar` | `sidebar` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI].
-| `defaultPopupOnStart` | The default popup to show on start. | `none` | Live reloadable | xref:action_customization/popuponstart.adoc[Popup On Start].
+| `defaultOnClick` | The default on-click behavior when an action starts. | `nothing` | Live reloadable | xref:action_customization/popuponstart.adoc[On Click].
+| `defaultPopupOnStart` | Legacy name for `defaultOnClick`. | - | Live reloadable | xref:action_customization/popuponstart.adoc[On Click].
 | `defaultIconForActions` | The default icon string for actions (Unicode aliases such as `smile`, `hugeicons:NeutralIcon`, HTML, Iconify snippets, images, etc.). See xref:action_customization/icons.adoc[Icons]. | `hugeicons:CommandLineIcon` | Requires Restart | -
 | `defaultIconForDirectories` | The default icon to use for directories. | `directory` | Requires Restart | -
 | `defaultIconForBack` | The default icon to use for back (from directories). | `&laquo;` | Requires Restart | -

+ 3 - 4
docs/modules/ROOT/pages/solutions/k8s-control-panel-hosted/index.adoc

@@ -15,7 +15,7 @@ image::solution-k8s-hosted.png[]
 === Environment
 
 * A Kubernetes cluster that is up and running.
-* Kubernetes permissions to create a helm deployment, a `ClusterRole` and `ClusterRoleBinding`. 
+* Kubernetes permissions to create a helm deployment, a `ClusterRole` and `ClusterRoleBinding`.
 * A configured Ingress Controller, exposing the for web interface
 
 === System
@@ -58,7 +58,7 @@ rules:
 --
 ====
 
-Now that the `ClusterRole` has been created, we need to associate it to a `ServiceAccount` with a `ClusterRoleBinding`. 
+Now that the `ClusterRole` has been created, we need to associate it to a `ServiceAccount` with a `ClusterRoleBinding`.
 Create a cluster role binding;
 
 [tabs]
@@ -82,7 +82,7 @@ Add `kubectl` job to OliveTin config with `kubectl edit cm/olivetin-config -n ol
 apiVersion: v1
 data:
   config.yaml: |
-    defaultPopupOnStart: execution-dialog-output-only
+    defaultOnClick: execution-dialog-output-only
 
     actions:
       - title: get pods
@@ -114,4 +114,3 @@ metadata:
 ----
 
 Don't forget to restart the OliveTin deployment as good measure, because Kubernetes can be slow to update configmaps.
-

+ 22 - 10
frontend/js/websocket.js

@@ -1,6 +1,11 @@
 import { buttonResults } from '../resources/vue/stores/buttonResults.js'
 import { rateLimits } from '../resources/vue/stores/rateLimits.js'
 import { connectionState } from '../resources/vue/stores/connectionState.js'
+import {
+  applyExecutionFinishedBindingState,
+  applyExecutionStartedBindingState
+} from '../resources/vue/stores/bindingExecutionState.js'
+import { cloneLogEntry } from '../resources/vue/utils/executionLogEvents.js'
 
 const RECONNECT_DELAYS_MS = [200, 1000, 2000, 4000, 8000, 16000, 32000]
 const BANNER_DELAY_MS = 2000
@@ -52,8 +57,8 @@ export function connectEventStreamIfNeeded () {
 export function initWebsocket () {
   if (!listenersInitialized) {
     window.addEventListener('EventOutputChunk', onOutputChunk)
-    window.addEventListener('EventExecutionStarted', onExecutionChanged)
-    window.addEventListener('EventExecutionFinished', onExecutionChanged)
+    window.addEventListener('EventExecutionStarted', onExecutionStarted)
+    window.addEventListener('EventExecutionFinished', onExecutionFinished)
     window.addEventListener('pagehide', stopEventStream)
     listenersInitialized = true
   }
@@ -250,20 +255,27 @@ function onOutputChunk (evt) {
 }
 
 export function applyExecutionLogEntry (logEntry) {
-  if (!logEntry?.executionTrackingId) {
+  const entry = cloneLogEntry(logEntry)
+  if (!entry?.executionTrackingId) {
     return
   }
 
-  buttonResults[logEntry.executionTrackingId] = logEntry
+  buttonResults[entry.executionTrackingId] = entry
 
-  if (logEntry.datetimeRateLimitExpires && logEntry.bindingId) {
-    const date = new Date(logEntry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
-    rateLimits[logEntry.bindingId] = date.getTime() / 1000
-  } else if (logEntry.bindingId) {
-    rateLimits[logEntry.bindingId] = 0
+  if (entry.datetimeRateLimitExpires && entry.bindingId) {
+    const date = new Date(entry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
+    rateLimits[entry.bindingId] = date.getTime() / 1000
+  } else if (entry.bindingId) {
+    rateLimits[entry.bindingId] = 0
   }
 }
 
-function onExecutionChanged (evt) {
+function onExecutionStarted (evt) {
   applyExecutionLogEntry(evt.payload.logEntry)
+  applyExecutionStartedBindingState(evt.payload.logEntry)
+}
+
+function onExecutionFinished (evt) {
+  applyExecutionLogEntry(evt.payload.logEntry)
+  applyExecutionFinishedBindingState(evt.payload.logEntry)
 }

+ 30 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -90,6 +90,21 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
    * @generated from field: repeated olivetin.api.v1.ActionWebhookExecHint exec_on_webhooks = 15;
    */
   execOnWebhooks: ActionWebhookExecHint[];
+
+  /**
+   * @generated from field: bool justification = 16;
+   */
+  justification: boolean;
+
+  /**
+   * @generated from field: bool has_running_instance = 17;
+   */
+  hasRunningInstance: boolean;
+
+  /**
+   * @generated from field: bool has_queued_instance = 18;
+   */
+  hasQueuedInstance: boolean;
 };
 
 /**
@@ -400,6 +415,11 @@ export declare type StartActionRequest = Message<"olivetin.api.v1.StartActionReq
    * @generated from field: string unique_tracking_id = 3;
    */
   uniqueTrackingId: string;
+
+  /**
+   * @generated from field: string justification = 4;
+   */
+  justification: string;
 };
 
 /**
@@ -458,6 +478,11 @@ export declare type StartActionAndWaitRequest = Message<"olivetin.api.v1.StartAc
    * @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 2;
    */
   arguments: StartActionArgument[];
+
+  /**
+   * @generated from field: string justification = 3;
+   */
+  justification: string;
 };
 
 /**
@@ -690,6 +715,11 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
    * @generated from field: string queued_for_group = 22;
    */
   queuedForGroup: string;
+
+  /**
+   * @generated from field: string justification = 23;
+   */
+  justification: string;
 };
 
 /**

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


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

@@ -1,5 +1,12 @@
 <template>
 	<div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
+		<span
+			v-if="showExecutionIndicator"
+			class="execution-indicator"
+			:class="executionIndicatorClass"
+			:title="executionIndicatorTitle"
+			aria-hidden="true"
+		></span>
 		<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
 													  :class="combinedClasses" @click="handleClick">
 
@@ -29,9 +36,12 @@
 <script setup>
 import { buttonResults } from './stores/buttonResults'
 import { rateLimits } from './stores/rateLimits'
+import { bindingExecutionState, setBindingExecutionState } from './stores/bindingExecutionState'
 import { connectionState } from './stores/connectionState'
 import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
 import { useRouter } from 'vue-router'
+import { needsArgumentForm } from './utils/needsArgumentForm.js'
+import { shouldSuppressPopupOnStartNavigation } from './utils/popupOnStartNavigation.js'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
 
@@ -93,6 +103,40 @@ const combinedClasses = computed(() => {
 	return classes
 })
 
+const hasRunningInstance = computed(() => {
+	const id = bindingId.value
+	return !!(id && bindingExecutionState[id]?.hasRunning)
+})
+
+const hasQueuedInstance = computed(() => {
+	const id = bindingId.value
+	return !!(id && bindingExecutionState[id]?.hasQueued)
+})
+
+const showExecutionIndicator = computed(() => {
+	return hasRunningInstance.value || hasQueuedInstance.value
+})
+
+const executionIndicatorClass = computed(() => {
+	if (hasRunningInstance.value) {
+		return 'execution-indicator-running'
+	}
+	if (hasQueuedInstance.value) {
+		return 'execution-indicator-queued'
+	}
+	return ''
+})
+
+const executionIndicatorTitle = computed(() => {
+	if (hasRunningInstance.value) {
+		return 'Running'
+	}
+	if (hasQueuedInstance.value) {
+		return 'Queued'
+	}
+	return ''
+})
+
 // Timestamps
 const updateIterationTimestamp = ref(0)
 
@@ -110,7 +154,7 @@ function constructFromJson(json) {
 	navigateOnStart.value = 'pop'
   } else if (popupOnStart.value === 'history') {
 	navigateOnStart.value = 'hist'
-  } else if (props.actionData.arguments.length > 0) {
+  } else if (needsArgumentForm(props.actionData)) {
 	navigateOnStart.value = 'arg'
   }
 
@@ -127,6 +171,11 @@ function constructFromJson(json) {
   // Also initialize the store so the watch picks it up
   if (bindingId.value) {
 	rateLimits[bindingId.value] = rateLimitExpires.value
+	setBindingExecutionState(
+	  bindingId.value,
+	  !!json.hasRunningInstance,
+	  !!json.hasQueuedInstance
+	)
   }
   updateRateLimitStatus()
 }
@@ -198,7 +247,7 @@ async function handleClick() {
 	openActionDetails()
 	return
   }
-  if (props.actionData.arguments && props.actionData.arguments.length > 0) {
+  if (needsArgumentForm(props.actionData)) {
 	router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
   } else {
 	await startAction()
@@ -300,7 +349,11 @@ function onExecutionQueued(_logEntry) {
 }
 
 function onExecutionStarted(logEntry) {
-  if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
+  if (
+	popupOnStart.value &&
+	popupOnStart.value.includes('execution-dialog') &&
+	!shouldSuppressPopupOnStartNavigation(router)
+  ) {
 	router.push(`/logs/${logEntry.executionTrackingId}`)
   }
 
@@ -397,6 +450,26 @@ defineExpose({
 		display: flex;
 		flex-direction: column;
 		flex-grow: 1;
+		position: relative;
+	}
+
+	.execution-indicator {
+		position: absolute;
+		top: 0.45em;
+		left: 0.45em;
+		width: 0.65em;
+		height: 0.65em;
+		border-radius: 50%;
+		z-index: 1;
+		pointer-events: none;
+	}
+
+	.execution-indicator-running {
+		background: #28a745;
+	}
+
+	.execution-indicator-queued {
+		background: #0d6efd;
 	}
 
 	.action-button button {

+ 31 - 7
frontend/resources/vue/components/ActionStatusDisplay.vue

@@ -1,6 +1,7 @@
 <template>
     <div :class = "statusClass + ' annotation'">
-        <span>{{ statusText }}</span><span>{{ exitCodeText }}</span>
+        <router-link v-if="showQueueLink" to="/logs/queue" class="queue-status-link">{{ statusText }}</router-link>
+        <span v-else>{{ statusText }}</span><span>{{ exitCodeText }}</span>
     </div>
 
 </template>
@@ -12,17 +13,23 @@ const props = defineProps({
     logEntry: {
         type: Object,
         required: true
+    },
+    linkQueuedStatus: {
+        type: Boolean,
+        default: false
     }
 })
 
+function isWaitingInQueue(logEntry) {
+    return logEntry &&
+        !logEntry.executionFinished &&
+        !logEntry.executionStarted
+}
+
 const statusText = computed(() => {
     const logEntry = props.logEntry
     if (!logEntry) return 'unknown'
 
-    if (logEntry.queued && !logEntry.executionFinished) {
-        return 'Queued'
-    }
-
     if (logEntry.executionFinished) {
         if (logEntry.blocked) {
             return 'Blocked'
@@ -31,9 +38,13 @@ const statusText = computed(() => {
         } else {
             return 'Completed'
         }
-    } else {
-        return 'Still running...'
     }
+
+    if (isWaitingInQueue(logEntry)) {
+        return 'Queued'
+    }
+
+    return 'Still running...'
 })
 
 const exitCodeText = computed(() => {
@@ -51,6 +62,10 @@ const exitCodeText = computed(() => {
     return ''
 })
 
+const showQueueLink = computed(() => {
+    return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
+})
+
 const statusClass = computed(() => {
     const logEntry = props.logEntry
     if (!logEntry) return ''
@@ -86,5 +101,14 @@ const statusClass = computed(() => {
   color: #ca79ff;
 }
 
+.queue-status-link {
+  color: #0d6efd;
+  text-decoration: none;
+}
+
+.queue-status-link:hover {
+  text-decoration: underline;
+}
+
 
 </style>

+ 27 - 0
frontend/resources/vue/components/LogActionTitle.vue

@@ -0,0 +1,27 @@
+<template>
+  <span class="log-action-title">
+    <slot>{{ actionTitle }}</slot>
+    <span v-if="justification" class="log-justification">— {{ justification }}</span>
+  </span>
+</template>
+
+<script setup>
+defineProps({
+  actionTitle: {
+    type: String,
+    default: ''
+  },
+  justification: {
+    type: String,
+    default: ''
+  }
+})
+</script>
+
+<style scoped>
+.log-justification {
+  font-size: 0.875rem;
+  color: #888;
+  font-weight: normal;
+}
+</style>

+ 79 - 0
frontend/resources/vue/stores/bindingExecutionState.js

@@ -0,0 +1,79 @@
+import { reactive } from 'vue'
+import { buttonResults } from './buttonResults.js'
+
+const INDICATOR_SHOW_DELAY_MS = 1000
+
+export const bindingExecutionState = reactive({})
+
+const pendingShowTimers = {}
+
+function cancelPendingShowTimer (bindingId) {
+  const timer = pendingShowTimers[bindingId]
+  if (timer != null) {
+    clearTimeout(timer)
+    delete pendingShowTimers[bindingId]
+  }
+}
+
+function scheduleIndicatorShow (bindingId) {
+  cancelPendingShowTimer(bindingId)
+
+  pendingShowTimers[bindingId] = setTimeout(() => {
+    delete pendingShowTimers[bindingId]
+    recomputeBindingExecutionState(bindingId)
+  }, INDICATOR_SHOW_DELAY_MS)
+}
+
+export function setBindingExecutionState (bindingId, hasRunning, hasQueued) {
+  if (!bindingId) {
+    return
+  }
+
+  if (!hasRunning && !hasQueued) {
+    delete bindingExecutionState[bindingId]
+    return
+  }
+
+  bindingExecutionState[bindingId] = { hasRunning, hasQueued }
+}
+
+export function recomputeBindingExecutionState (bindingId) {
+  if (!bindingId) {
+    return
+  }
+
+  let hasRunning = false
+  let hasQueued = false
+
+  for (const trackingId in buttonResults) {
+    const entry = buttonResults[trackingId]
+    if (!entry || entry.bindingId !== bindingId || entry.executionFinished) {
+      continue
+    }
+
+    if (entry.executionStarted) {
+      hasRunning = true
+    } else {
+      hasQueued = true
+    }
+  }
+
+  setBindingExecutionState(bindingId, hasRunning, hasQueued)
+}
+
+export function applyExecutionStartedBindingState (logEntry) {
+  if (!logEntry?.bindingId || logEntry.executionFinished) {
+    return
+  }
+
+  scheduleIndicatorShow(logEntry.bindingId)
+}
+
+export function applyExecutionFinishedBindingState (logEntry) {
+  if (!logEntry?.bindingId) {
+    return
+  }
+
+  cancelPendingShowTimer(logEntry.bindingId)
+  recomputeBindingExecutionState(logEntry.bindingId)
+}

+ 54 - 0
frontend/resources/vue/utils/executionLogEvents.js

@@ -0,0 +1,54 @@
+export function cloneLogEntry (logEntry) {
+  if (!logEntry) {
+    return null
+  }
+
+  return {
+    ...logEntry,
+    tags: Array.isArray(logEntry.tags) ? [...logEntry.tags] : logEntry.tags
+  }
+}
+
+export function getExecutionLogEntry (evt) {
+  const logEntry = evt?.payload?.logEntry ?? evt?.detail?.logEntry ?? null
+  return cloneLogEntry(logEntry)
+}
+
+export function updateLogEntryInList (entries, logEntry) {
+  if (!logEntry?.executionTrackingId || !entries) {
+    return false
+  }
+
+  const index = entries.findIndex(
+    item => item.executionTrackingId === logEntry.executionTrackingId
+  )
+  if (index < 0) {
+    return false
+  }
+
+  entries[index] = logEntry
+  return true
+}
+
+export function updateLogEntryInGroups (groups, logEntry) {
+  const entry = cloneLogEntry(logEntry)
+  if (!entry?.executionTrackingId || !groups) {
+    return null
+  }
+
+  for (const group of groups) {
+    const entries = group.entries || []
+    const index = entries.findIndex(
+      item => item.executionTrackingId === entry.executionTrackingId
+    )
+    if (index < 0) {
+      continue
+    }
+
+    const previous = entries[index]
+    entries[index] = entry
+    return { group, index, previous }
+  }
+
+  return null
+}

+ 3 - 0
frontend/resources/vue/utils/needsArgumentForm.js

@@ -0,0 +1,3 @@
+export function needsArgumentForm (action) {
+  return (action?.arguments?.length > 0) || action?.justification
+}

+ 4 - 0
frontend/resources/vue/utils/popupOnStartNavigation.js

@@ -0,0 +1,4 @@
+export function shouldSuppressPopupOnStartNavigation (router) {
+  const path = router?.currentRoute?.value?.path ?? window.location.pathname
+  return path === '/logs' || path === '/logs/queue'
+}

+ 35 - 11
frontend/resources/vue/views/ActionDetailsView.vue

@@ -53,7 +53,7 @@
       </div>
 
       <div v-show="filteredLogs.length > 0">
-        <table class="logs-table">
+        <table class="logs-table row-hover">
           <thead>
             <tr>
               <th>Timestamp</th>
@@ -82,7 +82,7 @@
                 </span>
               </td>
               <td class="exit-code">
-                <ActionStatusDisplay :logEntry="log" />
+                <ActionStatusDisplay :logEntry="log" :link-queued-status="true" />
               </td>
             </tr>
           </tbody>
@@ -107,6 +107,8 @@ import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import { requestReconnectNow } from '../../../js/websocket.js'
+import { needsArgumentForm } from '../utils/needsArgumentForm.js'
+import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 
 const route = useRoute()
 const router = useRouter()
@@ -266,13 +268,6 @@ function syncDurationTicker() {
   }, 1000)
 }
 
-onUnmounted(() => {
-  if (durationTicker != null) {
-    clearInterval(durationTicker)
-    durationTicker = null
-  }
-})
-
 function handlePageChange(page) {
   currentPage.value = page
   fetchActionLogs()
@@ -290,11 +285,16 @@ async function startAction() {
     return
   }
 
+  if (needsArgumentForm(action.value)) {
+    router.push(`/actionBinding/${action.value.bindingId}/argumentForm`)
+    return
+  }
+
   try {
     requestReconnectNow()
     const args = {
-      "bindingId": action.value.bindingId,
-      "arguments": []
+      bindingId: action.value.bindingId,
+      arguments: []
     }
 
     const response = await window.client.startAction(args)
@@ -305,9 +305,24 @@ async function startAction() {
   }
 }
 
+function onExecutionEvent(evt) {
+  const logEntry = getExecutionLogEntry(evt)
+  if (!logEntry || logEntry.bindingId !== route.params.actionId) {
+    return
+  }
+
+  if (!updateLogEntryInList(logs.value, logEntry)) {
+    fetchActionLogs()
+    return
+  }
+  syncDurationTicker()
+}
+
 onMounted(() => {
   fetchAction()
   fetchActionLogs()
+  window.addEventListener('EventExecutionStarted', onExecutionEvent)
+  window.addEventListener('EventExecutionFinished', onExecutionEvent)
 })
 
 watch(
@@ -319,6 +334,15 @@ watch(
   },
   { immediate: false }
 )
+
+onUnmounted(() => {
+  window.removeEventListener('EventExecutionStarted', onExecutionEvent)
+  window.removeEventListener('EventExecutionFinished', onExecutionEvent)
+  if (durationTicker != null) {
+    clearInterval(durationTicker)
+    durationTicker = null
+  }
+})
 </script>
 
 <style scoped>

+ 49 - 6
frontend/resources/vue/views/ArgumentForm.vue

@@ -40,7 +40,13 @@
             <span class="argument-description" v-html="arg.description"></span>
           </template>
         </template>
-        <div v-else>
+
+        <template v-if="justificationRequired">
+          <label for="justification">Justification:</label>
+          <input id="justification" name="justification" type="text" v-model="justificationValue" required />
+        </template>
+
+        <div v-if="actionArguments.length === 0 && !justificationRequired">
           <p>No arguments required</p>
         </div>
 
@@ -76,6 +82,8 @@ const formErrors = ref({})
 const actionArguments = ref([])
 const popupOnStart = ref('')
 const formReady = ref(false)
+const justificationRequired = ref(false)
+const justificationValue = ref('')
 let isComponentMounted = true
 
 // Computed properties
@@ -103,6 +111,8 @@ async function setup() {
     icon.value = action.icon
     popupOnStart.value = action.popupOnStart || ''
     actionArguments.value = action.arguments || []
+    justificationRequired.value = action.justification || false
+    justificationValue.value = ''
     argValues.value = {}
     formErrors.value = {}
     confirmationChecked.value = false
@@ -306,19 +316,41 @@ function updateUrlWithArg(name, value) {
   }
 }
 
+function shouldSendArgument(arg) {
+  if (!arg.name) {
+    return false
+  }
+
+  return arg.type !== 'html'
+}
+
+function formatArgumentValueForApi(arg, rawValue) {
+  if (arg.type === 'checkbox' || arg.type === 'confirmation') {
+    return rawValue === '1' || rawValue === true || rawValue === 'true' ? '1' : '0'
+  }
+
+  if (rawValue === true) {
+    return '1'
+  }
+
+  if (rawValue === false) {
+    return '0'
+  }
+
+  return rawValue ?? ''
+}
+
 function getArgumentValues() {
   const ret = []
 
   for (const arg of actionArguments.value) {
-    let value = argValues.value[arg.name] || ''
-
-    if (arg.type === 'checkbox' || arg.type === 'confirmation') {
-      value = value ? '1' : '0'
+    if (!shouldSendArgument(arg)) {
+      continue
     }
 
     ret.push({
       name: arg.name,
-      value: value
+      value: formatArgumentValueForApi(arg, argValues.value[arg.name])
     })
   }
 
@@ -394,6 +426,10 @@ async function startAction(actionArgs) {
     uniqueTrackingId: getUniqueId()
   }
 
+  if (justificationRequired.value) {
+    startActionArgs.justification = justificationValue.value
+  }
+
   try {
     requestReconnectNow()
     const response = await window.client.startAction(startActionArgs)
@@ -418,6 +454,13 @@ async function handleSubmit(event) {
   }
 
   // Set custom validity for required fields
+  if (justificationRequired.value && (!justificationValue.value || justificationValue.value.trim() === '')) {
+    const inputElement = document.getElementById('justification')
+    if (inputElement) {
+      inputElement.setCustomValidity('This field is required')
+    }
+  }
+
   for (const arg of actionArguments.value) {
     const value = argValues.value[arg.name]
     const inputElement = document.getElementById(arg.name)

+ 17 - 4
frontend/resources/vue/views/ExecutionView.vue

@@ -17,12 +17,17 @@
 
 		<div v-if="logEntry" class = "flex-row">
 				<dl class = "fg1">
+					<dt>Action</dt>
+					<dd>
+						<LogActionTitle :action-title="title" :justification="logEntry.justification" />
+					</dd>
+
 					<dt>Duration</dt>
 					<dd><span v-html="duration"></span></dd>
 
 					<dt>Status</dt>
-					<dd>
-						<ActionStatusDisplay :log-entry="logEntry" id = "execution-dialog-status" />
+					<dd class="execution-dialog-status">
+						<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
 					</dd>
 				</dl>
         <ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
@@ -64,6 +69,7 @@
 	import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
+import LogActionTitle from '../components/LogActionTitle.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
 import { HugeiconsIcon } from '@hugeicons/vue'
@@ -71,6 +77,7 @@ import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-fre
 import { useRouter } from 'vue-router'
 import { buttonResults } from '../stores/buttonResults'
 import { requestReconnectNow } from '../../../js/websocket.js'
+import { needsArgumentForm } from '../utils/needsArgumentForm.js'
 
 const router = useRouter()
 
@@ -178,10 +185,16 @@ async function rerunAction() {
   }
 
   try {
+    const binding = await window.client.getActionBinding({ bindingId })
+    if (needsArgumentForm(binding.action)) {
+      router.push(`/actionBinding/${bindingId}/argumentForm`)
+      return
+    }
+
     requestReconnectNow()
     const startActionArgs = {
-      "bindingId": bindingId,
-      "arguments": []
+      bindingId: bindingId,
+      arguments: []
     }
 
     const res = await window.client.startAction(startActionArgs)

+ 26 - 2
frontend/resources/vue/views/LogsListView.vue

@@ -42,7 +42,7 @@
       </div>
 
       <div v-show="logs.length > 0">
-        <table class="logs-table">
+        <table class="logs-table row-hover">
           <thead>
             <tr>
               <th>
@@ -70,7 +70,7 @@
               <td>
                 <ActionIconGlyph class="icon" :glyph="log.actionIcon" />
                 <router-link :to="`/logs/${log.executionTrackingId}`">
-                  {{ log.actionTitle }}
+                  <LogActionTitle :action-title="log.actionTitle" :justification="log.justification" />
                 </router-link>
               </td>
               <td class="tags">
@@ -123,6 +123,8 @@ import Section from 'picocrank/vue/components/Section.vue'
 import { useI18n } from 'vue-i18n'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
+import LogActionTitle from '../components/LogActionTitle.vue'
+import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 
 const route = useRoute()
 const router = useRouter()
@@ -254,8 +256,30 @@ function handlePageSizeChange(newPageSize) {
   fetchLogs()
 }
 
+function onExecutionEvent(evt) {
+  const logEntry = getExecutionLogEntry(evt)
+  if (!logEntry) {
+    return
+  }
+
+  if (!updateLogEntryInList(logs.value, logEntry)) {
+    fetchLogs()
+  }
+}
+
 onMounted(() => {
   updateDateFromRoute()
+  window.addEventListener('EventExecutionStarted', onExecutionEvent)
+  window.addEventListener('EventExecutionFinished', onExecutionEvent)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('EventExecutionStarted', onExecutionEvent)
+  window.removeEventListener('EventExecutionFinished', onExecutionEvent)
+  if (fetchTimer) {
+    clearTimeout(fetchTimer)
+    fetchTimer = null
+  }
 })
 
 onUnmounted(() => {

+ 240 - 98
frontend/resources/vue/views/LogsQueueView.vue

@@ -11,86 +11,240 @@
 
     <p class="padding">{{ t('logs.queue-page-description') }}</p>
 
-    <div v-if="groups.length > 0" class="queue-groups padding">
-      <section v-for="group in groups" :key="group.bindingId" class="queue-group">
-        <header class="queue-group-header">
-          <ActionIconGlyph class="icon" :glyph="group.actionIcon" />
-          <div class="queue-group-title">
-            <h3>{{ group.actionTitle }}</h3>
-            <p v-if="group.entityPrefix" class="queue-entity">
-              {{ t('logs.queue-entity') }}: {{ group.entityPrefix }}
-            </p>
-          </div>
-          <span class="queue-group-limit annotation">
-            {{ t('logs.queue-group-active', { active: group.activeCount, max: group.maxConcurrent }) }}
-          </span>
-        </header>
-
-        <table class="logs-table">
-          <thead>
-            <tr>
-              <th>{{ t('logs.timestamp') }}</th>
-              <th>{{ t('logs.metadata') }}</th>
-              <th>{{ t('logs.status') }}</th>
-            </tr>
-          </thead>
-          <tbody>
-            <tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row">
-              <td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</td>
-              <td class="tags">
-                <span class="annotation">
-                  <span class="annotation-key">User:</span>
-                  <span class="annotation-val">{{ entry.user }}</span>
-                </span>
-                <span v-if="entry.tags && entry.tags.length > 0" class="tag-list">
-                  <span v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
-                </span>
-                <span class="annotation">
-                  <span class="annotation-key">ID:</span>
-                  <router-link :to="`/logs/${entry.executionTrackingId}`">
-                    {{ entry.executionTrackingId }}
-                  </router-link>
-                </span>
-              </td>
-              <td class="exit-code">
-                <span class="annotation">
-                  <span class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span>
-                  <span :class="queueStatusClass(entry)">{{ queueStatusText(entry) }}</span>
-                </span>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </section>
-    </div>
-
-    <div v-else-if="!loading" class="empty-state padding">
+    <div v-if="groups.length === 0 && !loading" class="empty-state padding">
       <p>{{ t('logs.queue-empty') }}</p>
       <router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
     </div>
   </Section>
+
+  <section
+    v-for="group in groups"
+    :key="group.bindingId"
+    class="with-header-and-content queue-group-section"
+  >
+    <div class="section-header flex-row">
+      <div class="fg1 queue-group-heading">
+        <ActionIconGlyph class="queue-group-icon" :glyph="group.actionIcon" />
+        <div class="queue-group-title">
+          <h2 :title="group.entityPrefix ? `${t('logs.queue-entity')}: ${group.entityPrefix}` : ''">
+            {{ group.actionTitle }}
+          </h2>
+          <p v-if="group.entityPrefix" class="queue-entity">
+            {{ t('logs.queue-entity') }}: {{ group.entityPrefix }}
+          </p>
+        </div>
+      </div>
+      <div role="toolbar" class="queue-group-toolbar">
+        <router-link
+          v-if="group.bindingId"
+          :to="`/action/${group.bindingId}`"
+          class="button neutral"
+          :title="t('logs.queue-action-details')"
+        >
+          <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
+            <path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
+          </svg>
+          {{ t('logs.queue-action-details') }}
+        </router-link>
+        <span class="queue-group-limit annotation">
+          {{ t('logs.queue-group-active', { active: group.activeCount, max: group.maxConcurrent }) }}
+        </span>
+      </div>
+    </div>
+
+    <div class="section-content">
+      <table class="logs-table row-hover">
+      <thead>
+        <tr>
+          <th>{{ t('logs.timestamp') }}</th>
+          <th>{{ t('logs.metadata') }}</th>
+          <th>{{ t('logs.status') }}</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="(entry, index) in group.entries" :key="entry.executionTrackingId" class="log-row">
+          <td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</td>
+          <td class="tags">
+            <span class="annotation">
+              <span class="annotation-key">User:</span>
+              <span class="annotation-val">{{ entry.user }}</span>
+            </span>
+            <span v-if="entry.tags && entry.tags.length > 0" class="tag-list">
+              <span v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
+            </span>
+            <span class="annotation">
+              <span class="annotation-key">ID:</span>
+              <router-link :to="`/logs/${entry.executionTrackingId}`">
+                {{ entry.executionTrackingId }}
+              </router-link>
+            </span>
+          </td>
+          <td class="exit-code">
+            <span v-if="!entry.executionFinished" class="queue-position">{{ t('logs.queue-position', { position: index + 1 }) }}</span>
+            <ActionStatusDisplay :logEntry="entry" />
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    </div>
+  </section>
 </template>
 
 <script setup>
 import { ref, onMounted, onUnmounted } from 'vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
+import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import { useI18n } from 'vue-i18n'
+import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
 
 const { t } = useI18n()
 
 const groups = ref([])
 const loading = ref(false)
 
-function queueStatusText (entry) {
-  if (entry.executionStarted) {
-    return t('logs.queue-running')
+function collectCompletedEntries (currentGroups) {
+  const completed = []
+  for (const group of currentGroups || []) {
+    for (const entry of group.entries || []) {
+      if (entry.executionFinished) {
+        completed.push(cloneLogEntry(entry))
+      }
+    }
   }
-  return t('logs.queue-waiting')
+  return completed
+}
+
+function sortGroupEntries (entries) {
+  entries.sort((left, right) => {
+    if (left.executionFinished !== right.executionFinished) {
+      return left.executionFinished ? 1 : -1
+    }
+    return (left.datetimeStarted || '').localeCompare(right.datetimeStarted || '')
+  })
 }
 
-function queueStatusClass (entry) {
-  return entry.executionStarted ? 'queue-status-running' : 'queue-status-waiting'
+function sortGroups (groupList) {
+  groupList.sort((left, right) => {
+    const byTitle = (left.actionTitle || '').localeCompare(right.actionTitle || '')
+    if (byTitle !== 0) {
+      return byTitle
+    }
+    return (left.entityPrefix || '').localeCompare(right.entityPrefix || '')
+  })
+}
+
+function mergeCompletedEntries (apiGroups, completedEntries) {
+  const merged = (apiGroups || []).map(group => ({
+    ...group,
+    entries: [...(group.entries || [])]
+  }))
+
+  for (const entry of completedEntries) {
+    const alreadyPresent = merged.some(group =>
+      group.entries.some(item => item.executionTrackingId === entry.executionTrackingId)
+    )
+    if (alreadyPresent) {
+      continue
+    }
+
+    let group = merged.find(item => item.bindingId === entry.bindingId)
+    if (!group) {
+      group = {
+        bindingId: entry.bindingId,
+        actionTitle: entry.actionTitle,
+        actionIcon: entry.actionIcon,
+        entityPrefix: '',
+        maxConcurrent: 0,
+        activeCount: 0,
+        entries: []
+      }
+      merged.push(group)
+    }
+
+    group.entries.push(entry)
+  }
+
+  for (const group of merged) {
+    sortGroupEntries(group.entries)
+  }
+  sortGroups(merged)
+
+  return merged
+}
+
+function applyQueueEntryUpdate (logEntry, afterUpdate) {
+  const result = updateLogEntryInGroups(groups.value, logEntry)
+  if (!result) {
+    return false
+  }
+
+  if (afterUpdate) {
+    afterUpdate(result)
+  }
+  sortGroupEntries(result.group.entries)
+  return true
+}
+
+function adjustActiveCountOnStart (group, previous, logEntry) {
+  const wasActive = !previous.executionFinished
+  const isActive = !logEntry.executionFinished
+  if (!wasActive && isActive) {
+    group.activeCount++
+  }
+}
+
+function insertActiveQueueEntry (logEntry) {
+  if (!logEntry?.bindingId || !logEntry.executionTrackingId || logEntry.executionFinished) {
+    fetchQueue()
+    return
+  }
+
+  let group = groups.value.find(item => item.bindingId === logEntry.bindingId)
+  if (!group) {
+    group = {
+      bindingId: logEntry.bindingId,
+      actionTitle: logEntry.actionTitle || '',
+      actionIcon: logEntry.actionIcon || '',
+      entityPrefix: logEntry.entityPrefix || '',
+      maxConcurrent: 0,
+      activeCount: 0,
+      entries: []
+    }
+    groups.value.push(group)
+  }
+
+  group.entries.push(cloneLogEntry(logEntry))
+  adjustActiveCountOnStart(group, { executionFinished: true }, logEntry)
+  sortGroupEntries(group.entries)
+  sortGroups(groups.value)
+}
+
+function onExecutionStarted (evt) {
+  const logEntry = getExecutionLogEntry(evt)
+  if (!logEntry) {
+    return
+  }
+
+  if (!applyQueueEntryUpdate(logEntry, ({ group, previous }) => {
+    adjustActiveCountOnStart(group, previous, logEntry)
+  })) {
+    insertActiveQueueEntry(logEntry)
+  }
+}
+
+function onExecutionFinished (evt) {
+  const logEntry = getExecutionLogEntry(evt)
+  if (!logEntry) {
+    return
+  }
+
+  applyQueueEntryUpdate(logEntry, ({ group, previous }) => {
+    const wasActive = !previous.executionFinished
+    if (wasActive && logEntry.executionFinished && group.activeCount > 0) {
+      group.activeCount--
+    }
+  })
 }
 
 function formatTimestamp (timestamp) {
@@ -107,8 +261,9 @@ function formatTimestamp (timestamp) {
 async function fetchQueue () {
   loading.value = true
   try {
+    const completedEntries = collectCompletedEntries(groups.value)
     const response = await window.client.getExecutionQueue({})
-    groups.value = response.groups || []
+    groups.value = mergeCompletedEntries(response.groups || [], completedEntries)
   } catch (err) {
     console.error('Failed to fetch execution queue:', err)
     window.showBigError('fetch-queue', 'getting execution queue', err, false)
@@ -119,45 +274,37 @@ async function fetchQueue () {
 
 onMounted(() => {
   fetchQueue()
-  window.addEventListener('EventExecutionStarted', fetchQueue)
-  window.addEventListener('EventExecutionFinished', fetchQueue)
+  window.addEventListener('EventExecutionStarted', onExecutionStarted)
+  window.addEventListener('EventExecutionFinished', onExecutionFinished)
 })
 
 onUnmounted(() => {
-  window.removeEventListener('EventExecutionStarted', fetchQueue)
-  window.removeEventListener('EventExecutionFinished', fetchQueue)
+  window.removeEventListener('EventExecutionStarted', onExecutionStarted)
+  window.removeEventListener('EventExecutionFinished', onExecutionFinished)
 })
 </script>
 
 <style scoped>
-.queue-groups {
+.queue-group-heading {
   display: flex;
-  flex-direction: column;
-  gap: 1.5rem;
+  align-items: center;
+  gap: 0.75rem;
+  min-width: 0;
 }
 
-.queue-group {
-  border: 1px solid var(--border-color, #ccc);
-  border-radius: 0.5rem;
-  overflow: hidden;
+.queue-group-title h2 {
+  margin: 0;
 }
 
-.queue-group-header {
-  display: flex;
+.queue-group-toolbar {
+  display: inline-flex;
+  flex-wrap: wrap;
   align-items: center;
-  gap: 0.75rem;
-  padding: 0.75rem 1rem;
-  background: var(--section-background, #f8f9fa);
-  border-bottom: 1px solid var(--border-color, #ccc);
-}
-
-.queue-group-title {
-  flex: 1;
+  gap: 0.5rem;
 }
 
-.queue-group-title h3 {
-  margin: 0;
-  font-size: 1rem;
+.queue-group-limit {
+  white-space: nowrap;
 }
 
 .queue-entity {
@@ -166,12 +313,9 @@ onUnmounted(() => {
   color: #666;
 }
 
-.queue-group-limit {
-  white-space: nowrap;
-}
-
-.icon {
+.queue-group-icon {
   font-size: 1.5em;
+  flex-shrink: 0;
 }
 
 .timestamp {
@@ -185,16 +329,14 @@ onUnmounted(() => {
   font-size: smaller;
 }
 
-.queue-position {
-  margin-right: 0.5rem;
-}
-
-.queue-status-running {
-  color: var(--karma-warning-fg, #856404);
+.exit-code {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
 }
 
-.queue-status-waiting {
-  color: #0d6efd;
+.queue-position {
+  white-space: nowrap;
 }
 
 .empty-state {

+ 5 - 3
integration-tests/lib/elements.js

@@ -5,6 +5,8 @@ import { Condition } from 'selenium-webdriver'
 
 export const DEFAULT_UI_WAIT_MS = 3000
 
+const executionDialogStatusBy = By.css('.execution-dialog-status')
+
 export async function getActionButtons () {
   // Currently, only the active dashboard's contents are rendered,
   // so we don't need to scope the selector by dashboard title.
@@ -91,13 +93,13 @@ export async function waitForArgumentFormReady(timeoutMs = DEFAULT_UI_WAIT_MS) {
 
 export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
   await webdriver.wait(new Condition('wait for execution status', async () => {
-    const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
+    const statusElements = await webdriver.findElements(executionDialogStatusBy)
     return statusElements.length > 0
   }), timeoutMs)
 
   await webdriver.wait(new Condition('wait for execution to finish', async () => {
     try {
-      const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
+      const statusElement = await webdriver.findElement(executionDialogStatusBy)
       const statusText = await statusElement.getText()
       return !statusText.includes('Still running')
     } catch (e) {
@@ -163,7 +165,7 @@ export async function getNavigationLinks() {
 
 export async function requireExecutionDialogStatus (webdriver, expected) {
   await webdriver.wait(new Condition('wait for action to be running', async function () {
-    const dialogStatus = await webdriver.findElement(By.id('execution-dialog-status'))
+    const dialogStatus = await webdriver.findElement(executionDialogStatusBy)
     const actual = await dialogStatus.getText()
 
     if (actual === expected) {

+ 1 - 1
integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js

@@ -40,7 +40,7 @@ describe('config: entityFilesWithLongIntsUseStandardForm', function () {
     await waitForLogsPage()
     await waitForExecutionComplete()
 
-    const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
+    const statusElement = await webdriver.findElement(By.css('.execution-dialog-status'))
     const statusText = await statusElement.getText()
 
     // The status should indicate success (not "Executing..." or "Failed")

+ 1 - 1
lang/combined_output.json

@@ -350,4 +350,4 @@
             "welcome": "欢迎使用 OliveTin"
         }
     }
-}
+}

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

@@ -20,6 +20,9 @@ message Action {
 	repeated string exec_on_file_changed_in_dir = 13;
 	string exec_on_calendar_file = 14;
 	repeated ActionWebhookExecHint exec_on_webhooks = 15;
+	bool justification = 16;
+	bool has_running_instance = 17;
+	bool has_queued_instance = 18;
 }
 
 message ActionWebhookExecHint {
@@ -95,6 +98,7 @@ message StartActionRequest {
 	repeated StartActionArgument arguments = 2;
 
 	string unique_tracking_id = 3;
+	string justification = 4;
 }
 
 message StartActionArgument {
@@ -110,6 +114,8 @@ message StartActionAndWaitRequest {
 	string action_id = 1;
 
 	repeated StartActionArgument arguments = 2;
+
+	string justification = 3;
 }
 
 message StartActionAndWaitResponse {
@@ -160,6 +166,7 @@ message LogEntry {
 	string binding_id = 20; // Binding ID for matching rate limits to action buttons
 	bool queued = 21;
 	string queued_for_group = 22;
+	string justification = 23;
 }
 
 message GetLogsResponse {

+ 62 - 8
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -38,6 +38,9 @@ type Action struct {
 	ExecOnFileChangedInDir   []string                 `protobuf:"bytes,13,rep,name=exec_on_file_changed_in_dir,json=execOnFileChangedInDir,proto3" json:"exec_on_file_changed_in_dir,omitempty"`
 	ExecOnCalendarFile       string                   `protobuf:"bytes,14,opt,name=exec_on_calendar_file,json=execOnCalendarFile,proto3" json:"exec_on_calendar_file,omitempty"`
 	ExecOnWebhooks           []*ActionWebhookExecHint `protobuf:"bytes,15,rep,name=exec_on_webhooks,json=execOnWebhooks,proto3" json:"exec_on_webhooks,omitempty"`
+	Justification            bool                     `protobuf:"varint,16,opt,name=justification,proto3" json:"justification,omitempty"`
+	HasRunningInstance       bool                     `protobuf:"varint,17,opt,name=has_running_instance,json=hasRunningInstance,proto3" json:"has_running_instance,omitempty"`
+	HasQueuedInstance        bool                     `protobuf:"varint,18,opt,name=has_queued_instance,json=hasQueuedInstance,proto3" json:"has_queued_instance,omitempty"`
 	unknownFields            protoimpl.UnknownFields
 	sizeCache                protoimpl.SizeCache
 }
@@ -177,6 +180,27 @@ func (x *Action) GetExecOnWebhooks() []*ActionWebhookExecHint {
 	return nil
 }
 
+func (x *Action) GetJustification() bool {
+	if x != nil {
+		return x.Justification
+	}
+	return false
+}
+
+func (x *Action) GetHasRunningInstance() bool {
+	if x != nil {
+		return x.HasRunningInstance
+	}
+	return false
+}
+
+func (x *Action) GetHasQueuedInstance() bool {
+	if x != nil {
+		return x.HasQueuedInstance
+	}
+	return false
+}
+
 type ActionWebhookExecHint struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Template      string                 `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
@@ -802,6 +826,7 @@ type StartActionRequest struct {
 	BindingId        string                 `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"`
 	Arguments        []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"`
 	UniqueTrackingId string                 `protobuf:"bytes,3,opt,name=unique_tracking_id,json=uniqueTrackingId,proto3" json:"unique_tracking_id,omitempty"`
+	Justification    string                 `protobuf:"bytes,4,opt,name=justification,proto3" json:"justification,omitempty"`
 	unknownFields    protoimpl.UnknownFields
 	sizeCache        protoimpl.SizeCache
 }
@@ -857,6 +882,13 @@ func (x *StartActionRequest) GetUniqueTrackingId() string {
 	return ""
 }
 
+func (x *StartActionRequest) GetJustification() string {
+	if x != nil {
+		return x.Justification
+	}
+	return ""
+}
+
 type StartActionArgument struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Name          string                 `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@@ -957,6 +989,7 @@ type StartActionAndWaitRequest struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	ActionId      string                 `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"`
 	Arguments     []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"`
+	Justification string                 `protobuf:"bytes,3,opt,name=justification,proto3" json:"justification,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
@@ -1005,6 +1038,13 @@ func (x *StartActionAndWaitRequest) GetArguments() []*StartActionArgument {
 	return nil
 }
 
+func (x *StartActionAndWaitRequest) GetJustification() string {
+	if x != nil {
+		return x.Justification
+	}
+	return ""
+}
+
 type StartActionAndWaitResponse struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	LogEntry      *LogEntry              `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"`
@@ -1315,6 +1355,7 @@ type LogEntry struct {
 	BindingId                string                 `protobuf:"bytes,20,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"`                                                  // Binding ID for matching rate limits to action buttons
 	Queued                   bool                   `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"`
 	QueuedForGroup           string                 `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"`
+	Justification            string                 `protobuf:"bytes,23,opt,name=justification,proto3" json:"justification,omitempty"`
 	unknownFields            protoimpl.UnknownFields
 	sizeCache                protoimpl.SizeCache
 }
@@ -1489,6 +1530,13 @@ func (x *LogEntry) GetQueuedForGroup() string {
 	return ""
 }
 
+func (x *LogEntry) GetJustification() string {
+	if x != nil {
+		return x.Justification
+	}
+	return ""
+}
+
 type GetLogsResponse struct {
 	state          protoimpl.MessageState `protogen:"open.v1"`
 	Logs           []*LogEntry            `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
@@ -4275,7 +4323,7 @@ var File_olivetin_api_v1_olivetin_proto protoreflect.FileDescriptor
 
 const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\n" +
-	"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\x89\x05\n" +
+	"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\x91\x06\n" +
 	"\x06Action\x12\x1d\n" +
 	"\n" +
 	"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" +
@@ -4294,7 +4342,10 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x1bexec_on_file_created_in_dir\x18\f \x03(\tR\x16execOnFileCreatedInDir\x12;\n" +
 	"\x1bexec_on_file_changed_in_dir\x18\r \x03(\tR\x16execOnFileChangedInDir\x121\n" +
 	"\x15exec_on_calendar_file\x18\x0e \x01(\tR\x12execOnCalendarFile\x12P\n" +
-	"\x10exec_on_webhooks\x18\x0f \x03(\v2&.olivetin.api.v1.ActionWebhookExecHintR\x0eexecOnWebhooks\"\x8a\x03\n" +
+	"\x10exec_on_webhooks\x18\x0f \x03(\v2&.olivetin.api.v1.ActionWebhookExecHintR\x0eexecOnWebhooks\x12$\n" +
+	"\rjustification\x18\x10 \x01(\bR\rjustification\x120\n" +
+	"\x14has_running_instance\x18\x11 \x01(\bR\x12hasRunningInstance\x12.\n" +
+	"\x13has_queued_instance\x18\x12 \x01(\bR\x11hasQueuedInstance\"\x8a\x03\n" +
 	"\x15ActionWebhookExecHint\x12\x1a\n" +
 	"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" +
 	"\n" +
@@ -4359,20 +4410,22 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\ventity_type\x18\a \x01(\tR\n" +
 	"entityType\x12\x1d\n" +
 	"\n" +
-	"entity_key\x18\b \x01(\tR\tentityKey\"\xa5\x01\n" +
+	"entity_key\x18\b \x01(\tR\tentityKey\"\xcb\x01\n" +
 	"\x12StartActionRequest\x12\x1d\n" +
 	"\n" +
 	"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +
 	"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12,\n" +
-	"\x12unique_tracking_id\x18\x03 \x01(\tR\x10uniqueTrackingId\"?\n" +
+	"\x12unique_tracking_id\x18\x03 \x01(\tR\x10uniqueTrackingId\x12$\n" +
+	"\rjustification\x18\x04 \x01(\tR\rjustification\"?\n" +
 	"\x13StartActionArgument\x12\x12\n" +
 	"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
 	"\x05value\x18\x02 \x01(\tR\x05value\"I\n" +
 	"\x13StartActionResponse\x122\n" +
-	"\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"|\n" +
+	"\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"\xa2\x01\n" +
 	"\x19StartActionAndWaitRequest\x12\x1b\n" +
 	"\taction_id\x18\x01 \x01(\tR\bactionId\x12B\n" +
-	"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\"T\n" +
+	"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12$\n" +
+	"\rjustification\x18\x03 \x01(\tR\rjustification\"T\n" +
 	"\x1aStartActionAndWaitResponse\x126\n" +
 	"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" +
 	"\x17StartActionByGetRequest\x12\x1b\n" +
@@ -4388,7 +4441,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\vdate_filter\x18\x02 \x01(\tR\n" +
 	"dateFilter\x12\x1b\n" +
 	"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\n" +
-	"\x06filter\x18\x04 \x01(\tR\x06filter\"\xcb\x05\n" +
+	"\x06filter\x18\x04 \x01(\tR\x06filter\"\xf1\x05\n" +
 	"\bLogEntry\x12)\n" +
 	"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
 	"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
@@ -4413,7 +4466,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\n" +
 	"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
 	"\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" +
-	"\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\"\xca\x01\n" +
+	"\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\x12$\n" +
+	"\rjustification\x18\x17 \x01(\tR\rjustification\"\xca\x01\n" +
 	"\x0fGetLogsResponse\x12-\n" +
 	"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
 	"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +

+ 50 - 48
service/internal/api/api.go

@@ -150,35 +150,30 @@ func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUse
 }
 
 func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
-	args := make(map[string]string)
-
-	for _, arg := range req.Msg.Arguments {
-		args[arg.Name] = arg.Value
-	}
-
-	pair := api.executor.FindBindingByID(req.Msg.BindingId)
-
-	if pair == nil || pair.Action == nil {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
+	pair, err := api.findBindingByIDOrNotFound(req.Msg.BindingId)
+	if err != nil {
+		return nil, err
 	}
 
 	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
+	if err := validateJustificationRequired(pair.Action, req.Msg.Justification, authenticatedUser); err != nil {
+		return nil, connectInvalidJustification(err)
+	}
 
 	execReq := executor.ExecutionRequest{
 		Binding:           pair,
 		TrackingID:        req.Msg.UniqueTrackingId,
-		Arguments:         args,
+		Arguments:         startActionArgumentsFromProto(req.Msg.Arguments),
+		Justification:     req.Msg.Justification,
 		AuthenticatedUser: authenticatedUser,
 		Cfg:               api.cfg,
 	}
 
 	api.executor.ExecRequest(&execReq)
 
-	ret := &apiv1.StartActionResponse{
+	return connect.NewResponse(&apiv1.StartActionResponse{
 		ExecutionTrackingId: execReq.TrackingID,
-	}
-
-	return connect.NewResponse(ret), nil
+	}), nil
 }
 
 func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
@@ -259,11 +254,12 @@ func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[api
 	return response, nil
 }
 
-func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
+func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
 	execReq := executor.ExecutionRequest{
 		Binding:           binding,
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
+		Justification:     justification,
 		AuthenticatedUser: user,
 		Cfg:               api.cfg,
 	}
@@ -280,19 +276,22 @@ func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.Action
 	return binding, nil
 }
 
+func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.ActionBinding, error) {
+	return api.findBindingOrNotFound(bindingId)
+}
+
 func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
 	binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
 	if err != nil {
 		return nil, err
 	}
 
-	args := make(map[string]string)
-	for _, arg := range req.Msg.Arguments {
-		args[arg.Name] = arg.Value
-	}
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	if err := validateJustificationRequired(binding.Action, req.Msg.Justification, user); err != nil {
+		return nil, connectInvalidJustification(err)
+	}
 
-	internalLogEntry, ok := api.startActionAndWaitRun(binding, args, user)
+	internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user)
 	if !ok {
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
 	}
@@ -388,6 +387,7 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
 		User:                     logEntry.Username,
 		BindingId:                logEntry.GetBindingId(),
 		DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
+		Justification:            logEntry.Justification,
 	}
 
 	if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
@@ -531,11 +531,7 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
 	}
 
 	return &apiv1.GetActionBindingResponse{
-		Action: buildAction(binding, &DashboardRenderRequest{
-			cfg:               api.cfg,
-			AuthenticatedUser: user,
-			ex:                api.executor,
-		}),
+		Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
 	}, nil
 }
 
@@ -576,13 +572,15 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser)
 }
 
 func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
-	return &DashboardRenderRequest{
+	rr := &DashboardRenderRequest{
 		AuthenticatedUser: user,
 		cfg:               api.cfg,
 		ex:                api.executor,
 		EntityType:        entityType,
 		EntityKey:         entityKey,
 	}
+	populateActiveBindingStates(rr)
+	return rr
 }
 
 func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
@@ -1515,30 +1513,16 @@ func serializeEntityFields(data any) map[string]string {
 }
 
 func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
-	ret := &apiv1.StartActionResponse{
-		ExecutionTrackingId: req.Msg.ExecutionTrackingId,
-	}
-
-	execReqLogEntry, found := api.executor.GetLog(req.Msg.ExecutionTrackingId)
-
-	if !found {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
-	}
-
-	if execReqLogEntry.Binding == nil {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId))
+	execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId)
+	if err != nil {
+		return nil, err
 	}
 
-	action := execReqLogEntry.Binding.Action
-
-	if action == nil {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
+	if execReqLogEntry.Binding.Action.Justification {
+		return nil, restartRequiresJustificationError()
 	}
 
 	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
-
-	// TrackingID is deliberately not passed to the executor, so that it generates a new one for the restarted execution.
-	// This is because the old execution (identified by the old TrackingID) is already used.
 	execReq := executor.ExecutionRequest{
 		Binding:           execReqLogEntry.Binding,
 		Arguments:         make(map[string]string),
@@ -1548,8 +1532,26 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
 
 	api.executor.ExecRequest(&execReq)
 
-	ret.ExecutionTrackingId = execReq.TrackingID
-	return connect.NewResponse(ret), nil
+	return connect.NewResponse(&apiv1.StartActionResponse{
+		ExecutionTrackingId: execReq.TrackingID,
+	}), nil
+}
+
+func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*executor.InternalLogEntry, error) {
+	execReqLogEntry, found := api.executor.GetLog(executionTrackingId)
+	if !found {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", executionTrackingId))
+	}
+
+	if execReqLogEntry.Binding == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", executionTrackingId))
+	}
+
+	if execReqLogEntry.Binding.Action == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", executionTrackingId))
+	}
+
+	return execReqLogEntry, nil
 }
 
 func newServer(ex *executor.Executor) *oliveTinAPI {

+ 84 - 27
service/internal/api/apiActions.go

@@ -16,12 +16,56 @@ import (
 	"github.com/OliveTin/OliveTin/internal/tpl"
 )
 
+type bindingActiveState struct {
+	hasRunning bool
+	hasQueued  bool
+}
+
 type DashboardRenderRequest struct {
-	AuthenticatedUser *authpublic.AuthenticatedUser
-	cfg               *config.Config
-	ex                *executor.Executor
-	EntityType        string
-	EntityKey         string
+	AuthenticatedUser   *authpublic.AuthenticatedUser
+	cfg                 *config.Config
+	ex                  *executor.Executor
+	EntityType          string
+	EntityKey           string
+	activeBindingStates map[string]bindingActiveState
+}
+
+func activeBindingID(entry *executor.InternalLogEntry) string {
+	if entry == nil || entry.ExecutionFinished {
+		return ""
+	}
+	return entry.GetBindingId()
+}
+
+func applyEntryToBindingState(state bindingActiveState, entry *executor.InternalLogEntry) bindingActiveState {
+	if entry.ExecutionStarted {
+		state.hasRunning = true
+	} else {
+		state.hasQueued = true
+	}
+	return state
+}
+
+func buildActiveBindingStates(active []*executor.InternalLogEntry) map[string]bindingActiveState {
+	states := make(map[string]bindingActiveState)
+
+	for _, entry := range active {
+		bindingID := activeBindingID(entry)
+		if bindingID == "" {
+			continue
+		}
+		states[bindingID] = applyEntryToBindingState(states[bindingID], entry)
+	}
+
+	return states
+}
+
+func populateActiveBindingStates(rr *DashboardRenderRequest) {
+	if rr == nil || rr.ex == nil || rr.activeBindingStates != nil {
+		return
+	}
+
+	rr.activeBindingStates = buildActiveBindingStates(rr.ex.GetActiveExecutionsACL(rr.cfg, rr.AuthenticatedUser))
 }
 
 func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
@@ -135,43 +179,56 @@ func actionFromBinding(actionBinding *executor.ActionBinding) (*executor.ActionB
 	return actionBinding, actionBinding.Action
 }
 
+func applyActiveBindingStateToAction(btn *apiv1.Action, bindingID string, states map[string]bindingActiveState) {
+	if states == nil {
+		return
+	}
+	state, ok := states[bindingID]
+	if !ok {
+		return
+	}
+	btn.HasRunningInstance = state.hasRunning
+	btn.HasQueuedInstance = state.hasQueued
+}
+
+func buildActionArguments(action *config.Action, entity *entities.Entity) []*apiv1.ActionArgument {
+	args := make([]*apiv1.ActionArgument, 0, len(action.Arguments))
+	for _, cfgArg := range action.Arguments {
+		args = append(args, &apiv1.ActionArgument{
+			Name:                  cfgArg.Name,
+			Title:                 cfgArg.Title,
+			Type:                  cfgArg.Type,
+			Description:           cfgArg.Description,
+			DefaultValue:          getDefaultArgumentValue(cfgArg, entity),
+			Choices:               buildChoices(cfgArg),
+			Suggestions:           cfgArg.Suggestions,
+			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
+		})
+	}
+	return args
+}
+
 func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
 	binding, action := actionFromBinding(actionBinding)
 	if binding == nil {
 		return nil
 	}
 
-	aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action)
-	enabledExprCanExec := evaluateEnabledExpression(action, binding.Entity)
-	datetimeRateLimitExpires := formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding))
-
 	btn := apiv1.Action{
 		BindingId:                binding.ID,
 		Title:                    tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
 		Icon:                     tpl.ParseTemplateOfActionBeforeExec(action.Icon, binding.Entity),
-		CanExec:                  aclCanExec && enabledExprCanExec,
-		PopupOnStart:             action.PopupOnStart,
+		CanExec:                  acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action) && evaluateEnabledExpression(action, binding.Entity),
+		PopupOnStart:             action.OnClick,
 		Order:                    int32(binding.ConfigOrder),
 		Timeout:                  int32(action.Timeout),
-		DatetimeRateLimitExpires: datetimeRateLimitExpires,
+		DatetimeRateLimitExpires: formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding)),
+		Justification:            action.Justification,
 	}
 
+	applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
 	applyActionExecTriggers(&btn, action)
-
-	for _, cfgArg := range action.Arguments {
-		pbArg := apiv1.ActionArgument{
-			Name:                  cfgArg.Name,
-			Title:                 cfgArg.Title,
-			Type:                  cfgArg.Type,
-			Description:           cfgArg.Description,
-			DefaultValue:          getDefaultArgumentValue(cfgArg, binding.Entity),
-			Choices:               buildChoices(cfgArg),
-			Suggestions:           cfgArg.Suggestions,
-			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
-		}
-
-		btn.Arguments = append(btn.Arguments, &pbArg)
-	}
+	btn.Arguments = buildActionArguments(action, binding.Entity)
 
 	return &btn
 }

+ 92 - 0
service/internal/api/api_actions_active_test.go

@@ -0,0 +1,92 @@
+package api
+
+import (
+	"testing"
+
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/executor"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBuildActiveBindingStates(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{Title: "backup", Shell: "sleep 1", MaxConcurrent: 1},
+		{Title: "ping", Shell: "echo ping"},
+	}
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	backupBinding := ex.FindBindingWithNoEntity(cfg.Actions[0])
+	pingBinding := ex.FindBindingWithNoEntity(cfg.Actions[1])
+	require.NotNil(t, backupBinding)
+	require.NotNil(t, pingBinding)
+
+	backupRunning := newAPIQueueLogEntry(backupBinding, true, false)
+	backupWaiting := newAPIQueueLogEntry(backupBinding, false, false)
+	pingRunning := newAPIQueueLogEntry(pingBinding, true, false)
+
+	states := buildActiveBindingStates([]*executor.InternalLogEntry{
+		backupRunning,
+		backupWaiting,
+		pingRunning,
+	})
+
+	backupState, ok := states[backupBinding.ID]
+	require.True(t, ok)
+	assert.True(t, backupState.hasRunning)
+	assert.True(t, backupState.hasQueued)
+
+	pingState, ok := states[pingBinding.ID]
+	require.True(t, ok)
+	assert.True(t, pingState.hasRunning)
+	assert.False(t, pingState.hasQueued)
+}
+
+func TestBuildActionIncludesActiveBindingState(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{Title: "backup", Shell: "sleep 1"},
+	}
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
+	require.NotNil(t, binding)
+
+	running := newAPIQueueLogEntry(binding, true, false)
+	queued := newAPIQueueLogEntry(binding, false, false)
+	ex.SetLog(running.ExecutionTrackingID, running)
+	ex.SetLog(queued.ExecutionTrackingID, queued)
+
+	rr := &DashboardRenderRequest{
+		cfg: cfg,
+		ex:  ex,
+	}
+	populateActiveBindingStates(rr)
+
+	action := buildAction(binding, rr)
+	require.NotNil(t, action)
+	assert.True(t, action.HasRunningInstance)
+	assert.True(t, action.HasQueuedInstance)
+}
+
+func TestBuildActiveBindingStatesIgnoresFinished(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{{Title: "backup", Shell: "sleep 1"}}
+	cfg.Sanitize()
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
+	require.NotNil(t, binding)
+
+	finished := newAPIQueueLogEntry(binding, true, true)
+	states := buildActiveBindingStates([]*executor.InternalLogEntry{finished})
+	assert.Empty(t, states)
+}

+ 45 - 0
service/internal/api/api_justification.go

@@ -0,0 +1,45 @@
+package api
+
+import (
+	"fmt"
+	"strings"
+
+	"connectrpc.com/connect"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func validateJustificationRequired(action *config.Action, justification string, user *authpublic.AuthenticatedUser) error {
+	if !actionRequiresJustificationConfig(action) || justificationProvided(justification, user) {
+		return nil
+	}
+
+	return fmt.Errorf("justification is required for this action")
+}
+
+func actionRequiresJustificationConfig(action *config.Action) bool {
+	return action != nil && action.Justification
+}
+
+func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool {
+	return strings.TrimSpace(justification) != "" || executor.IsSystemExecution(user)
+}
+
+func connectInvalidJustification(err error) error {
+	return connect.NewError(connect.CodeInvalidArgument, err)
+}
+
+func startActionArgumentsFromProto(args []*apiv1.StartActionArgument) map[string]string {
+	result := make(map[string]string, len(args))
+	for _, arg := range args {
+		result[arg.Name] = arg.Value
+	}
+	return result
+}
+
+func restartRequiresJustificationError() error {
+	return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead"))
+}

+ 89 - 0
service/internal/api/api_justification_test.go

@@ -0,0 +1,89 @@
+package api
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	"github.com/OliveTin/OliveTin/internal/auth"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/executor"
+)
+
+func TestStartActionRequiresJustificationForGuest(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{
+		Title:         "Send email",
+		ID:            "send_email",
+		Justification: true,
+		Shell:         "echo done",
+	}
+	cfg.Actions = append(cfg.Actions, action)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	binding := ex.FindBindingWithNoEntity(action)
+	require.NotNil(t, binding)
+
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	_, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
+		BindingId:        binding.ID,
+		UniqueTrackingId: uuid.NewString(),
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
+
+	resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
+		BindingId:        binding.ID,
+		UniqueTrackingId: uuid.NewString(),
+		Justification:    "New user registration foo@example.com",
+	}))
+	require.NoError(t, err)
+	require.NotEmpty(t, resp.Msg.ExecutionTrackingId)
+
+	time.Sleep(200 * time.Millisecond)
+
+	entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId)
+	require.True(t, ok)
+	assert.Equal(t, "New user registration foo@example.com", entry.Justification)
+}
+
+func TestBuildActionExposesJustificationFlag(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{
+		Title:         "Audited action",
+		ID:            "audited",
+		Justification: true,
+		Shell:         "echo hi",
+	}
+	cfg.Actions = append(cfg.Actions, action)
+
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	binding := ex.FindBindingWithNoEntity(action)
+	require.NotNil(t, binding)
+
+	pb := buildAction(binding, &DashboardRenderRequest{
+		cfg: cfg,
+		ex:  ex,
+	})
+
+	require.NotNil(t, pb)
+	assert.True(t, pb.Justification)
+}
+
+func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{Title: "Cron job", Justification: true}
+
+	err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron"))
+	require.NoError(t, err)
+}

+ 13 - 8
service/internal/config/config.go

@@ -30,10 +30,12 @@ type Action struct {
 	MaxConcurrent          int              `koanf:"maxConcurrent"`
 	MaxRate                []RateSpec       `koanf:"maxRate"`
 	Arguments              []ActionArgument `koanf:"arguments"`
+	OnClick                string           `koanf:"onclick"`
 	PopupOnStart           string           `koanf:"popupOnStart"`
 	SaveLogs               SaveLogsConfig   `koanf:"saveLogs"`
 	EnabledExpression      string           `koanf:"enabledExpression"`
 	Groups                 []string         `koanf:"groups"`
+	Justification          bool             `koanf:"justification"`
 }
 
 // ActionGroup defines shared limits and metadata for a set of actions.
@@ -70,14 +72,15 @@ type RateSpec struct {
 
 // WebhookConfig defines configuration for generic webhook triggers.
 type WebhookConfig struct {
-	Secret       string            `koanf:"secret"`       // Optional: secret for signature verification
-	AuthType     string            `koanf:"authType"`     // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
-	AuthHeader   string            `koanf:"authHeader"`   // Optional: custom header name for auth (default: "X-Webhook-Signature")
-	MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
-	MatchPath    string            `koanf:"matchPath"`    // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
-	MatchQuery   map[string]string `koanf:"matchQuery"`   // Match URL query parameters
-	Extract      map[string]string `koanf:"extract"`      // Map action argument names to JSONPath expressions
-	Template     string            `koanf:"template"`     // Optional: template name (e.g., "github-push", "github-pr")
+	Secret        string            `koanf:"secret"`        // Optional: secret for signature verification
+	AuthType      string            `koanf:"authType"`      // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
+	AuthHeader    string            `koanf:"authHeader"`    // Optional: custom header name for auth (default: "X-Webhook-Signature")
+	MatchHeaders  map[string]string `koanf:"matchHeaders"`  // Match HTTP headers
+	MatchPath     string            `koanf:"matchPath"`     // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
+	MatchQuery    map[string]string `koanf:"matchQuery"`    // Match URL query parameters
+	Extract       map[string]string `koanf:"extract"`       // Map action argument names to JSONPath expressions
+	Template      string            `koanf:"template"`      // Optional: template name (e.g., "github-push", "github-pr")
+	Justification string            `koanf:"justification"` // Optional JSONPath to extract justification from webhook body
 }
 
 // Entity represents a "thing" that can have multiple actions associated with it.
@@ -175,6 +178,7 @@ type Config struct {
 	WebUIDir                        string                     `koanf:"webUIDir"`
 	CronSupportForSeconds           bool                       `koanf:"cronSupportForSeconds"`
 	SectionNavigationStyle          string                     `koanf:"sectionNavigationStyle"`
+	DefaultOnClick                  string                     `koanf:"defaultOnClick"`
 	DefaultPopupOnStart             string                     `koanf:"defaultPopupOnStart"`
 	InsecureAllowDumpOAuth2UserData bool                       `koanf:"insecureAllowDumpOAuth2UserData"`
 	InsecureAllowDumpVars           bool                       `koanf:"insecureAllowDumpVars"`
@@ -290,6 +294,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
 	config.WebUIDir = "./webui"
 	config.CronSupportForSeconds = false
 	config.SectionNavigationStyle = "sidebar"
+	config.DefaultOnClick = "nothing"
 	config.DefaultPopupOnStart = "nothing"
 	config.InsecureAllowDumpVars = false
 	config.InsecureAllowDumpSos = false

+ 39 - 3
service/internal/config/sanitize.go

@@ -18,6 +18,7 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeLogHistoryPageSize()
 	cfg.sanitizeLocalUsers()
 	cfg.sanitizeSecurityHeaders()
+	cfg.sanitizeOnClickDefaults()
 
 	// log.Infof("cfg %p", cfg)
 
@@ -177,7 +178,9 @@ func (action *Action) sanitize(cfg *Config) {
 
 	action.ID = getActionID(action)
 	action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
-	action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg)
+	migrateActionOnClick(action)
+	action.OnClick = sanitizeOnClick(action.OnClick, cfg)
+	action.PopupOnStart = action.OnClick
 
 	if action.MaxConcurrent < 1 {
 		action.MaxConcurrent = 1
@@ -359,7 +362,7 @@ func getActionID(action *Action) string {
 }
 
 //gocyclo:ignore
-func sanitizePopupOnStart(raw string, cfg *Config) string {
+func sanitizeOnClick(raw string, cfg *Config) string {
 	switch raw {
 	case "execution-dialog":
 		return raw
@@ -372,10 +375,43 @@ func sanitizePopupOnStart(raw string, cfg *Config) string {
 	case "history":
 		return raw
 	default:
-		return cfg.DefaultPopupOnStart
+		return cfg.DefaultOnClick
 	}
 }
 
+func migrateActionOnClick(action *Action) {
+	if action.OnClick == "" && action.PopupOnStart != "" {
+		action.OnClick = action.PopupOnStart
+	}
+}
+
+func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool {
+	if popupOnStart == "" {
+		return false
+	}
+	if onClick == "" {
+		return true
+	}
+	return onClick == "nothing" && popupOnStart != "nothing"
+}
+
+func (cfg *Config) migrateDefaultOnClickFromLegacyPopup() {
+	if !shouldMigrateDefaultOnClickFromPopup(cfg.DefaultOnClick, cfg.DefaultPopupOnStart) {
+		return
+	}
+	cfg.DefaultOnClick = cfg.DefaultPopupOnStart
+}
+
+func (cfg *Config) sanitizeOnClickDefaults() {
+	cfg.migrateDefaultOnClickFromLegacyPopup()
+
+	if cfg.DefaultOnClick == "" {
+		cfg.DefaultOnClick = "nothing"
+	}
+
+	cfg.DefaultPopupOnStart = cfg.DefaultOnClick
+}
+
 func (arg *ActionArgument) sanitize() {
 	if arg.Title == "" {
 		arg.Title = arg.Name

+ 61 - 1
service/internal/config/sanitize_test.go

@@ -52,10 +52,70 @@ func TestSanitizePopupOnStartHistory(t *testing.T) {
 
 	a := c.findAction("With history")
 	if assert.NotNil(t, a) {
-		assert.Equal(t, "history", a.PopupOnStart, "history must be preserved, not replaced by defaultPopupOnStart")
+		assert.Equal(t, "history", a.OnClick, "history must be preserved on onclick")
+		assert.Equal(t, "history", a.PopupOnStart, "legacy popupOnStart must mirror onclick")
 	}
 }
 
+func TestSanitizeMigratesPopupOnStartToOnClick(t *testing.T) {
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title:        "Legacy popup",
+		PopupOnStart: "execution-dialog",
+		Shell:        "true",
+	})
+	c.Sanitize()
+
+	a := c.findAction("Legacy popup")
+	require.NotNil(t, a)
+	assert.Equal(t, "execution-dialog", a.OnClick)
+	assert.Equal(t, "execution-dialog", a.PopupOnStart)
+}
+
+func TestSanitizeOnClickPreferredOverPopupOnStart(t *testing.T) {
+	c := DefaultConfig()
+	c.Actions = append(c.Actions, &Action{
+		Title:        "Preferred onclick",
+		OnClick:      "history",
+		PopupOnStart: "execution-dialog",
+		Shell:        "true",
+	})
+	c.Sanitize()
+
+	a := c.findAction("Preferred onclick")
+	require.NotNil(t, a)
+	assert.Equal(t, "history", a.OnClick)
+	assert.Equal(t, "history", a.PopupOnStart)
+}
+
+func TestSanitizeMigratesDefaultPopupOnStartToDefaultOnClick(t *testing.T) {
+	c := DefaultConfig()
+	c.DefaultPopupOnStart = "execution-dialog"
+	c.DefaultOnClick = ""
+	c.Sanitize()
+
+	assert.Equal(t, "execution-dialog", c.DefaultOnClick)
+	assert.Equal(t, "execution-dialog", c.DefaultPopupOnStart)
+}
+
+func TestSanitizeMigratesDefaultPopupOnStartWhenDefaultOnClickUnchanged(t *testing.T) {
+	c := DefaultConfig()
+	c.DefaultPopupOnStart = "execution-dialog"
+	c.Actions = append(c.Actions, &Action{
+		Title: "Uses default onclick",
+		Shell: "true",
+	})
+	c.Sanitize()
+
+	assert.Equal(t, "execution-dialog", c.DefaultOnClick)
+	assert.Equal(t, "execution-dialog", c.DefaultPopupOnStart)
+
+	a := c.findAction("Uses default onclick")
+	require.NotNil(t, a)
+	assert.Equal(t, "execution-dialog", a.OnClick)
+	assert.Equal(t, "execution-dialog", a.PopupOnStart)
+}
+
 func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 	c := DefaultConfig()
 

+ 17 - 4
service/internal/executor/executor.go

@@ -87,6 +87,7 @@ type ExecutionRequest struct {
 	Cfg               *config.Config
 	AuthenticatedUser *authpublic.AuthenticatedUser
 	TriggerDepth      int
+	Justification     string
 
 	logEntry                *InternalLogEntry
 	finalParsedCommand      string
@@ -166,8 +167,9 @@ type InternalLogEntry struct {
 		that logs are lightweight (so we don't need to have an action associated to
 		logs, etc. Therefore, we duplicate those values here.
 	*/
-	ActionTitle string
-	ActionIcon  string
+	ActionTitle   string
+	ActionIcon    string
+	Justification string
 }
 
 // .Binding can be nil, so we need to handle that.
@@ -280,7 +282,7 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
 	trackingIds := make([]*InternalLogEntry, 0, pageCount)
 
 	if totalLogCount > 0 {
-		for i := endIndex; i <= startIndex; i++ {
+		for i := startIndex; i >= endIndex; i-- {
 			trackingIds = append(trackingIds, e.logs[e.logsTrackingIdsByDate[i]])
 		}
 	}
@@ -376,7 +378,7 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC
 	endIndex := max(0, (startIndex-pageCount)+1)
 
 	out := make([]*InternalLogEntry, 0, pageCount)
-	for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
+	for i := startIndex; i >= endIndex && i < int64(len(filtered)); i-- {
 		out = append(out, filtered[i])
 	}
 
@@ -996,6 +998,7 @@ func stepRequestActionPopulateLogEntry(req *ExecutionRequest) {
 		entry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity)
 		entry.ActionIcon = req.Binding.Action.Icon
 		entry.Tags = req.Tags
+		entry.Justification = ResolveJustification(req)
 		if req.Binding.Entity != nil {
 			entry.EntityPrefix = req.Binding.Entity.UniqueKey
 		}
@@ -1148,9 +1151,18 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque
 	cmd.Stdout = streamer
 	cmd.Stderr = streamer
 	cmd.Env = buildEnv(req.Arguments)
+
+	started := false
 	req.mutateLogEntry(func(entry *InternalLogEntry) {
+		if entry.ExecutionStarted {
+			return
+		}
 		entry.ExecutionStarted = true
+		started = true
 	})
+	if started {
+		notifyListenersStarted(req)
+	}
 }
 
 func stepExecAfter(req *ExecutionRequest) bool {
@@ -1284,6 +1296,7 @@ func triggerLoop(req *ExecutionRequest) {
 			Arguments:         req.Arguments,
 			Cfg:               req.Cfg,
 			TriggerDepth:      req.TriggerDepth + 1,
+			Justification:     fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle),
 		}
 
 		req.executor.ExecRequest(trigger)

+ 97 - 0
service/internal/executor/group_concurrency_test.go

@@ -79,6 +79,79 @@ func TestGroupConcurrencyQueuesSecondAction(t *testing.T) {
 	assert.Contains(t, snapshot.Output, "queued-run")
 }
 
+func TestQueuedActionNotifiesWhenExecutionBegins(t *testing.T) {
+	t.Parallel()
+
+	slowAction := &config.Action{
+		Title:  "Hold group",
+		Shell:  "sleep 1",
+		Groups: []string{"unity"},
+	}
+	queuedAction := &config.Action{
+		Title:  "Queued job",
+		Shell:  "echo queued-run",
+		Groups: []string{"unity"},
+	}
+
+	e, cfg := testGroupExecutor(
+		[]*config.Action{slowAction, queuedAction},
+		map[string]*config.ActionGroup{
+			"unity": {MaxConcurrent: 1},
+		},
+	)
+
+	notifications := make(chan startedNotification, 8)
+	e.AddListener(&executionStartedCollector{ch: notifications})
+
+	wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
+		Binding:           e.FindBindingWithNoEntity(slowAction),
+		Cfg:               cfg,
+		AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
+	})
+
+	waitUntilExecutionStarted(t, e, tracking1)
+
+	wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
+		Binding:           e.FindBindingWithNoEntity(queuedAction),
+		Cfg:               cfg,
+		AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
+	})
+
+	require.Eventually(t, func() bool {
+		snapshot, ok := e.SnapshotLog(tracking2)
+		return ok && snapshot.Queued
+	}, time.Second, 10*time.Millisecond)
+
+	wg1.Wait()
+	wg2.Wait()
+
+	sawQueuedStart, sawRunningStart := collectQueuedStartNotifications(notifications, tracking2)
+
+	assert.True(t, sawQueuedStart, "queued action should notify when queued")
+	assert.True(t, sawRunningStart, "queued action should notify again when execution begins")
+}
+
+func isQueuedStartNotification(notification startedNotification, trackingID string) bool {
+	return notification.trackingID == trackingID && notification.queued && !notification.started
+}
+
+func isRunningStartNotification(notification startedNotification, trackingID string) bool {
+	return notification.trackingID == trackingID && !notification.queued && notification.started
+}
+
+func collectQueuedStartNotifications(notifications <-chan startedNotification, trackingID string) (sawQueuedStart, sawRunningStart bool) {
+	for len(notifications) > 0 {
+		notification := <-notifications
+		if isQueuedStartNotification(notification, trackingID) {
+			sawQueuedStart = true
+		}
+		if isRunningStartNotification(notification, trackingID) {
+			sawRunningStart = true
+		}
+	}
+	return sawQueuedStart, sawRunningStart
+}
+
 func TestDifferentGroupsRunConcurrently(t *testing.T) {
 	t.Parallel()
 
@@ -213,6 +286,30 @@ func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) {
 	}, 2*time.Second, 10*time.Millisecond)
 }
 
+type executionStartedCollector struct {
+	ch chan startedNotification
+}
+
+type startedNotification struct {
+	trackingID string
+	started    bool
+	queued     bool
+}
+
+func (c *executionStartedCollector) OnExecutionStarted(entry *InternalLogEntry) {
+	c.ch <- startedNotification{
+		trackingID: entry.ExecutionTrackingID,
+		started:    entry.ExecutionStarted,
+		queued:     entry.Queued,
+	}
+}
+
+func (c *executionStartedCollector) OnExecutionFinished(_ *InternalLogEntry) {}
+
+func (c *executionStartedCollector) OnOutputChunk(_ []byte, _ string) {}
+
+func (c *executionStartedCollector) OnActionMapRebuilt() {}
+
 func assertWaitGroupPending(t *testing.T, wg *sync.WaitGroup) {
 	t.Helper()
 

+ 68 - 0
service/internal/executor/justification.go

@@ -0,0 +1,68 @@
+package executor
+
+import (
+	"fmt"
+	"strings"
+
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+)
+
+const (
+	justificationCron       = "Triggered by cron"
+	justificationStartup    = "Triggered by startup"
+	justificationFileChange = "Triggered by file change"
+	justificationCalendar   = "Triggered by calendar"
+	justificationWebhook    = "Triggered by webhook"
+)
+
+var systemJustificationDefaults = map[string]string{
+	"cron":      justificationCron,
+	"startup":   justificationStartup,
+	"fileindir": justificationFileChange,
+	"calendar":  justificationCalendar,
+	"webhook":   justificationWebhook,
+}
+
+func IsSystemExecution(user *authpublic.AuthenticatedUser) bool {
+	if user == nil || user.Provider != "system" {
+		return false
+	}
+
+	return user.Username != "guest"
+}
+
+func ResolveJustification(req *ExecutionRequest) string {
+	provided := strings.TrimSpace(reqJustification(req))
+	if provided != "" {
+		return provided
+	}
+
+	if !actionRequiresJustification(req) {
+		return ""
+	}
+
+	return defaultJustificationForRequest(req)
+}
+
+func actionRequiresJustification(req *ExecutionRequest) bool {
+	return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.Justification
+}
+
+func defaultJustificationForRequest(req *ExecutionRequest) string {
+	if req.TriggerDepth > 0 && req.logEntry != nil {
+		return fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle)
+	}
+
+	if req.AuthenticatedUser == nil {
+		return ""
+	}
+
+	return systemJustificationDefaults[req.AuthenticatedUser.Username]
+}
+
+func reqJustification(req *ExecutionRequest) string {
+	if req == nil {
+		return ""
+	}
+	return req.Justification
+}

+ 130 - 0
service/internal/executor/justification_test.go

@@ -0,0 +1,130 @@
+package executor
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/OliveTin/OliveTin/internal/auth"
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func TestResolveJustificationUsesProvidedValue(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{Title: "Send email", Justification: true, Shell: "echo hi"}
+	cfg.Actions = append(cfg.Actions, action)
+	ex := DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	req := &ExecutionRequest{
+		Binding:           ex.FindBindingWithNoEntity(action),
+		Justification:     "New user registration foo@example.com",
+		AuthenticatedUser: auth.UserGuest(cfg),
+		Cfg:               cfg,
+	}
+	req.logEntry = &InternalLogEntry{}
+
+	assert.Equal(t, "New user registration foo@example.com", ResolveJustification(req))
+}
+
+func TestResolveJustificationCronDefault(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{Title: "Nightly backup", Justification: true, Shell: "echo hi"}
+	cfg.Actions = append(cfg.Actions, action)
+	ex := DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	req := &ExecutionRequest{
+		Binding:           ex.FindBindingWithNoEntity(action),
+		AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
+		Cfg:               cfg,
+	}
+
+	assert.Equal(t, justificationCron, ResolveJustification(req))
+}
+
+func TestResolveJustificationStartupDefault(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{Title: "Init", Justification: true, Shell: "echo hi"}
+	cfg.Actions = append(cfg.Actions, action)
+	ex := DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	req := &ExecutionRequest{
+		Binding:           ex.FindBindingWithNoEntity(action),
+		AuthenticatedUser: auth.UserFromSystem(cfg, "startup"),
+		Cfg:               cfg,
+	}
+
+	assert.Equal(t, justificationStartup, ResolveJustification(req))
+}
+
+func TestResolveJustificationWebhookDefault(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{Title: "Deploy", Justification: true, Exec: []string{"echo", "deploy"}}
+	cfg.Actions = append(cfg.Actions, action)
+	ex := DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	req := &ExecutionRequest{
+		Binding:           ex.FindBindingWithNoEntity(action),
+		AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
+		Cfg:               cfg,
+	}
+
+	assert.Equal(t, justificationWebhook, ResolveJustification(req))
+}
+
+func TestResolveJustificationEmptyWhenNotRequired(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{Title: "Ping", Shell: "echo hi"}
+	cfg.Actions = append(cfg.Actions, action)
+	ex := DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	req := &ExecutionRequest{
+		Binding:           ex.FindBindingWithNoEntity(action),
+		AuthenticatedUser: auth.UserGuest(cfg),
+		Cfg:               cfg,
+	}
+
+	assert.Empty(t, ResolveJustification(req))
+}
+
+func TestJustificationNotPassedToShellArgs(t *testing.T) {
+	cfg := config.DefaultConfig()
+	action := &config.Action{
+		Title:         "Echo",
+		Justification: true,
+		Shell:         "echo {{ message }}",
+		Arguments: []config.ActionArgument{
+			{Name: "message", Type: "ascii_sentence"},
+		},
+	}
+	cfg.Actions = append(cfg.Actions, action)
+	ex := DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	req := &ExecutionRequest{
+		Binding: ex.FindBindingWithNoEntity(action),
+		Arguments: map[string]string{
+			"message":       "hello",
+			"justification": "should be stripped",
+		},
+		Justification:     "audit reason",
+		AuthenticatedUser: auth.UserGuest(cfg),
+		Cfg:               cfg,
+	}
+	req.logEntry = &InternalLogEntry{}
+
+	filterToDefinedArgumentsOnly(req)
+
+	assert.Equal(t, "hello", req.Arguments["message"])
+	assert.Empty(t, req.Arguments["justification"])
+}
+
+func TestIsSystemExecution(t *testing.T) {
+	cfg := config.DefaultConfig()
+	assert.True(t, IsSystemExecution(auth.UserFromSystem(cfg, "cron")))
+	assert.False(t, IsSystemExecution(auth.UserGuest(cfg)))
+}

+ 12 - 2
service/internal/webhooks/handler.go

@@ -137,11 +137,20 @@ func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *htt
 		return false
 	}
 
-	h.executeAction(actionConfig.Action, args)
+	justification, err := matcher.ExtractJustification()
+	if err != nil {
+		log.WithFields(log.Fields{
+			"actionTitle": actionConfig.Action.Title,
+			"error":       err,
+		}).Warnf("Failed to extract webhook justification")
+		return false
+	}
+
+	h.executeAction(actionConfig.Action, args, justification)
 	return true
 }
 
-func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) {
+func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string, justification string) {
 	binding := h.executor.FindBindingWithNoEntity(action)
 	if binding == nil {
 		log.WithFields(log.Fields{
@@ -156,6 +165,7 @@ func (h *WebhookHandler) executeAction(action *config.Action, args map[string]st
 		Cfg:               h.cfg,
 		Tags:              []string{"webhook"},
 		Arguments:         definedArgs,
+		Justification:     justification,
 		AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
 	}
 

+ 13 - 0
service/internal/webhooks/matcher.go

@@ -138,6 +138,19 @@ func (m *WebhookMatcher) compareValues(actual, expected string) bool {
 	return actual == expected
 }
 
+func (m *WebhookMatcher) ExtractJustification() (string, error) {
+	if m.config.Justification == "" {
+		return "", nil
+	}
+
+	matcher, err := NewJSONMatcher(m.bodyBytes)
+	if err != nil {
+		return "", err
+	}
+
+	return matcher.ExtractValue(m.config.Justification)
+}
+
 func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) {
 	matcher, err := NewJSONMatcher(m.bodyBytes)
 	if err != nil {

+ 36 - 0
service/internal/webhooks/matcher_justification_test.go

@@ -0,0 +1,36 @@
+package webhooks
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func TestExtractJustificationFromWebhookBody(t *testing.T) {
+	body := []byte(`{"message":"deploy production","repo":"my-app"}`)
+	req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil)
+	require.NoError(t, err)
+
+	matcher := NewWebhookMatcher(config.WebhookConfig{
+		Justification: "$.message",
+	}, req, body)
+
+	value, err := matcher.ExtractJustification()
+	require.NoError(t, err)
+	assert.Equal(t, "deploy production", value)
+}
+
+func TestExtractJustificationEmptyWhenNotConfigured(t *testing.T) {
+	req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil)
+	require.NoError(t, err)
+
+	matcher := NewWebhookMatcher(config.WebhookConfig{}, req, []byte(`{}`))
+
+	value, err := matcher.ExtractJustification()
+	require.NoError(t, err)
+	assert.Empty(t, value)
+}

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff