Просмотр исходного кода

feat: circle indicator for running actions, justification support

jamesread 2 недель назад
Родитель
Сommit
de63793b3a
45 измененных файлов с 1693 добавлено и 324 удалено
  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
       - name: Install Node.js
         uses: actions/setup-node@v4
         uses: actions/setup-node@v4
         with:
         with:
-          node-version: '20'
+          node-version: '22'
 
 
       - name: Install Antora toolchain
       - name: Install Antora toolchain
         run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6
         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`.
 - 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.
 - 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
 ### Troubleshooting
 - API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
 - 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.
 - 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
 # Docs: https://docs.olivetin.app/action_execution/create_your_first.html
 actions:
 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
   # 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
   # below are optional *additional* triggers (see each action and
   # https://docs.olivetin.app/action_execution/ ).
   # https://docs.olivetin.app/action_execution/ ).
@@ -34,47 +55,19 @@ actions:
   - title: Ping the Internet
   - title: Ping the Internet
     shell: ping -c 3 1.1.1.1
     shell: ping -c 3 1.1.1.1
     icon: ping
     icon: ping
-    popupOnStart: execution-dialog-stdout-only
+    onclick: execution-dialog
     # https://docs.olivetin.app/action_execution/onstartup.html
     # https://docs.olivetin.app/action_execution/onstartup.html
     execOnStartup: true
     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.
   # 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:
     maxRate:
       - limit: 3
       - limit: 3
         duration: 1m
         duration: 1m
-    execOnCron:
-      - "@hourly"
 
 
   # You are not limited to operating system commands, and of course you can run
   # 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
   # your own scripts. The backup-jobs action group limits how many backup-related
@@ -86,7 +79,7 @@ actions:
     groups: [ backup-jobs ]
     groups: [ backup-jobs ]
     timeout: 10
     timeout: 10
     icon: backup
     icon: backup
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
     # https://docs.olivetin.app/action_execution/oncalendar.html
     # https://docs.olivetin.app/action_execution/oncalendar.html
     execOnCalendarFile: examples/demo-olivetin-calendar.yaml
     execOnCalendarFile: examples/demo-olivetin-calendar.yaml
 
 
@@ -95,7 +88,7 @@ actions:
     groups: [ backup-jobs ]
     groups: [ backup-jobs ]
     timeout: 30
     timeout: 30
     icon: backup
     icon: backup
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
 
 
   # When you want to prompt users for input, that is when you should use
   # 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.
   # `arguments` - this presents a popup dialog and asks for argument values.
@@ -106,7 +99,7 @@ actions:
     shell: ping {{ host }} -c {{ count }}
     shell: ping {{ host }} -c {{ count }}
     icon: ping
     icon: ping
     timeout: 100
     timeout: 100
-    popupOnStart: execution-dialog-stdout-only
+    onclick: history
     # https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
     # https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
     # with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
     # with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
     execOnWebhook:
     execOnWebhook:
@@ -148,7 +141,8 @@ actions:
   # Docs: https://docs.olivetin.app/args/input_confirmation.html
   # Docs: https://docs.olivetin.app/args/input_confirmation.html
   - title: Delete old backups
   - title: Delete old backups
     icon: ashtonished
     icon: ashtonished
-    shell: rm -rf /opt/oldBackups/
+    justification: true
+    shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
     arguments:
     arguments:
       - type: html
       - type: html
         title: Description
         title: Description
@@ -186,7 +180,7 @@ actions:
   - title: "Setup easy SSH"
   - title: "Setup easy SSH"
     icon: ssh
     icon: ssh
     shell: olivetin-setup-easy-ssh
     shell: olivetin-setup-easy-ssh
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
     # Second webhook example: POST /webhooks?demo=setup-ssh
     # Second webhook example: POST /webhooks?demo=setup-ssh
     execOnWebhook:
     execOnWebhook:
       - matchQuery:
       - matchQuery:
@@ -203,13 +197,6 @@ actions:
     timeout: 1
     timeout: 1
     shell: ssh -F /config/ssh/easy.cfg root@server1 'service httpd restart'
     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
   # There are several built-in shortcuts for the `icon` option, but you
   # can also just specify any HTML, this includes any unicode character,
   # can also just specify any HTML, this includes any unicode character,
   # or a <img = "..." /> link to a custom icon.
   # or a <img = "..." /> link to a custom icon.
@@ -274,6 +261,14 @@ actions:
     entity: container
     entity: container
     triggers: ["Update container entity file"]
     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
   # 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
   # background helpers that execute only on startup or a cron, for updating
   # entity files.
   # entity files.

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

@@ -48,7 +48,7 @@
 ** xref:action_execution/aftercompletion.adoc[Execute after completion]
 ** xref:action_execution/aftercompletion.adoc[Execute after completion]
 ** xref:action_execution/triggers.adoc[Triggers]
 ** xref:action_execution/triggers.adoc[Triggers]
 * xref:action_customization/intro.adoc[Action Customization]
 * 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/icons.adoc[Icons]
 ** xref:action_customization/timeouts.adoc[Timeouts]
 ** xref:action_customization/timeouts.adoc[Timeouts]
 ** xref:action_customization/users.adoc[Users]
 ** 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/concurrency.adoc[Control concurrency] - Limit simultaneous executions
 * xref:action_customization/ratelimiting.adoc[Set rate limits] - Prevent actions from running too frequently
 * 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/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/savelogs.adoc[Save action logs] - Configure log retention for actions
 * xref:action_customization/ids.adoc[Set action IDs] - Assign IDs for API access
 * 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)
 == Big Flashy Buttons (default)
 
 
@@ -13,7 +14,7 @@ You can also set the default for OliveTin using the `defaultPopupOnStart` config
 ----
 ----
 actions:
 actions:
   - title: Ping the Internet
   - title: Ping the Internet
-    popupOnStart: default
+    onclick: default
 ----
 ----
 
 
 This will also be the option that is used if no other values match.
 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:
 actions:
   - title: Check disk space
   - title: Check disk space
-    popupOnStart: execution-dialog-stdout-only
+    onclick: execution-dialog-stdout-only
 ----
 ----
 
 
 [NOTE]
 [NOTE]
@@ -39,22 +40,22 @@ image::../popupOutputOnly.png[]
 
 
 == Execution Dialog
 == 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]
 [source,yaml]
 .`config.yaml`
 .`config.yaml`
 ----
 ----
 actions:
 actions:
   - title: Check dmesg logs
   - title: Check dmesg logs
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
 ----
 ----
 
 
-.Example of `popupOnStart: execution-dialog`
+.Example of `onclick: execution-dialog`
 image::../executionDialog.png[]
 image::../executionDialog.png[]
 
 
 == Execution Buttons
 == 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.
 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:
 actions:
   - title: date
   - title: date
-    popupOnStart: execution-button
+    onclick: execution-button
 ----
 ----
 
 
 image::../executionButtons.png[]
 image::../executionButtons.png[]
@@ -77,7 +78,5 @@ The `history` option opens the action details page for that binding when the exe
 ----
 ----
 actions:
 actions:
   - title: Long-running job
   - 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:
 actions:
   - title: Setup SSH
   - title: Setup SSH
     shell: olivetin-setup-easy-ssh
     shell: olivetin-setup-easy-ssh
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
 ----
 ----
 
 
 [#ssh-easy-step-2]
 [#ssh-easy-step-2]

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

@@ -1,7 +1,7 @@
 [#customize-webui]
 [#customize-webui]
 = Customize the web UI
 = 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
 == 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:
 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
 * **Argument form** — the action opens an argument form on start
 * **Run in background** — the action runs without opening a dialog
 * **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]
 [#show-new-versions]
 == New version available - show/hide
 == 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`
 .`config.yaml`
 [source,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.
 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`.
 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.
 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.
 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
 === 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`.
 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].
 | `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].
 | `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].
 | `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 | -
 | `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 | -
 | `defaultIconForDirectories` | The default icon to use for directories. | `directory` | Requires Restart | -
 | `defaultIconForBack` | The default icon to use for back (from directories). | `&laquo;` | 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
 === Environment
 
 
 * A Kubernetes cluster that is up and running.
 * 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
 * A configured Ingress Controller, exposing the for web interface
 
 
 === System
 === 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;
 Create a cluster role binding;
 
 
 [tabs]
 [tabs]
@@ -82,7 +82,7 @@ Add `kubectl` job to OliveTin config with `kubectl edit cm/olivetin-config -n ol
 apiVersion: v1
 apiVersion: v1
 data:
 data:
   config.yaml: |
   config.yaml: |
-    defaultPopupOnStart: execution-dialog-output-only
+    defaultOnClick: execution-dialog-output-only
 
 
     actions:
     actions:
       - title: get pods
       - 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.
 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 { buttonResults } from '../resources/vue/stores/buttonResults.js'
 import { rateLimits } from '../resources/vue/stores/rateLimits.js'
 import { rateLimits } from '../resources/vue/stores/rateLimits.js'
 import { connectionState } from '../resources/vue/stores/connectionState.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 RECONNECT_DELAYS_MS = [200, 1000, 2000, 4000, 8000, 16000, 32000]
 const BANNER_DELAY_MS = 2000
 const BANNER_DELAY_MS = 2000
@@ -52,8 +57,8 @@ export function connectEventStreamIfNeeded () {
 export function initWebsocket () {
 export function initWebsocket () {
   if (!listenersInitialized) {
   if (!listenersInitialized) {
     window.addEventListener('EventOutputChunk', onOutputChunk)
     window.addEventListener('EventOutputChunk', onOutputChunk)
-    window.addEventListener('EventExecutionStarted', onExecutionChanged)
-    window.addEventListener('EventExecutionFinished', onExecutionChanged)
+    window.addEventListener('EventExecutionStarted', onExecutionStarted)
+    window.addEventListener('EventExecutionFinished', onExecutionFinished)
     window.addEventListener('pagehide', stopEventStream)
     window.addEventListener('pagehide', stopEventStream)
     listenersInitialized = true
     listenersInitialized = true
   }
   }
@@ -250,20 +255,27 @@ function onOutputChunk (evt) {
 }
 }
 
 
 export function applyExecutionLogEntry (logEntry) {
 export function applyExecutionLogEntry (logEntry) {
-  if (!logEntry?.executionTrackingId) {
+  const entry = cloneLogEntry(logEntry)
+  if (!entry?.executionTrackingId) {
     return
     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)
   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;
    * @generated from field: repeated olivetin.api.v1.ActionWebhookExecHint exec_on_webhooks = 15;
    */
    */
   execOnWebhooks: ActionWebhookExecHint[];
   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;
    * @generated from field: string unique_tracking_id = 3;
    */
    */
   uniqueTrackingId: string;
   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;
    * @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 2;
    */
    */
   arguments: StartActionArgument[];
   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;
    * @generated from field: string queued_for_group = 22;
    */
    */
   queuedForGroup: string;
   queuedForGroup: string;
+
+  /**
+   * @generated from field: string justification = 23;
+   */
+  justification: string;
 };
 };
 
 
 /**
 /**

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


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

@@ -1,5 +1,12 @@
 <template>
 <template>
 	<div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
 	<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"
 		<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
 													  :class="combinedClasses" @click="handleClick">
 													  :class="combinedClasses" @click="handleClick">
 
 
@@ -29,9 +36,12 @@
 <script setup>
 <script setup>
 import { buttonResults } from './stores/buttonResults'
 import { buttonResults } from './stores/buttonResults'
 import { rateLimits } from './stores/rateLimits'
 import { rateLimits } from './stores/rateLimits'
+import { bindingExecutionState, setBindingExecutionState } from './stores/bindingExecutionState'
 import { connectionState } from './stores/connectionState'
 import { connectionState } from './stores/connectionState'
 import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
 import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
+import { needsArgumentForm } from './utils/needsArgumentForm.js'
+import { shouldSuppressPopupOnStartNavigation } from './utils/popupOnStartNavigation.js'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
 import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
 
 
@@ -93,6 +103,40 @@ const combinedClasses = computed(() => {
 	return classes
 	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
 // Timestamps
 const updateIterationTimestamp = ref(0)
 const updateIterationTimestamp = ref(0)
 
 
@@ -110,7 +154,7 @@ function constructFromJson(json) {
 	navigateOnStart.value = 'pop'
 	navigateOnStart.value = 'pop'
   } else if (popupOnStart.value === 'history') {
   } else if (popupOnStart.value === 'history') {
 	navigateOnStart.value = 'hist'
 	navigateOnStart.value = 'hist'
-  } else if (props.actionData.arguments.length > 0) {
+  } else if (needsArgumentForm(props.actionData)) {
 	navigateOnStart.value = 'arg'
 	navigateOnStart.value = 'arg'
   }
   }
 
 
@@ -127,6 +171,11 @@ function constructFromJson(json) {
   // Also initialize the store so the watch picks it up
   // Also initialize the store so the watch picks it up
   if (bindingId.value) {
   if (bindingId.value) {
 	rateLimits[bindingId.value] = rateLimitExpires.value
 	rateLimits[bindingId.value] = rateLimitExpires.value
+	setBindingExecutionState(
+	  bindingId.value,
+	  !!json.hasRunningInstance,
+	  !!json.hasQueuedInstance
+	)
   }
   }
   updateRateLimitStatus()
   updateRateLimitStatus()
 }
 }
@@ -198,7 +247,7 @@ async function handleClick() {
 	openActionDetails()
 	openActionDetails()
 	return
 	return
   }
   }
-  if (props.actionData.arguments && props.actionData.arguments.length > 0) {
+  if (needsArgumentForm(props.actionData)) {
 	router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
 	router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
   } else {
   } else {
 	await startAction()
 	await startAction()
@@ -300,7 +349,11 @@ function onExecutionQueued(_logEntry) {
 }
 }
 
 
 function onExecutionStarted(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}`)
 	router.push(`/logs/${logEntry.executionTrackingId}`)
   }
   }
 
 
@@ -397,6 +450,26 @@ defineExpose({
 		display: flex;
 		display: flex;
 		flex-direction: column;
 		flex-direction: column;
 		flex-grow: 1;
 		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 {
 	.action-button button {

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

@@ -1,6 +1,7 @@
 <template>
 <template>
     <div :class = "statusClass + ' annotation'">
     <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>
     </div>
 
 
 </template>
 </template>
@@ -12,17 +13,23 @@ const props = defineProps({
     logEntry: {
     logEntry: {
         type: Object,
         type: Object,
         required: true
         required: true
+    },
+    linkQueuedStatus: {
+        type: Boolean,
+        default: false
     }
     }
 })
 })
 
 
+function isWaitingInQueue(logEntry) {
+    return logEntry &&
+        !logEntry.executionFinished &&
+        !logEntry.executionStarted
+}
+
 const statusText = computed(() => {
 const statusText = computed(() => {
     const logEntry = props.logEntry
     const logEntry = props.logEntry
     if (!logEntry) return 'unknown'
     if (!logEntry) return 'unknown'
 
 
-    if (logEntry.queued && !logEntry.executionFinished) {
-        return 'Queued'
-    }
-
     if (logEntry.executionFinished) {
     if (logEntry.executionFinished) {
         if (logEntry.blocked) {
         if (logEntry.blocked) {
             return 'Blocked'
             return 'Blocked'
@@ -31,9 +38,13 @@ const statusText = computed(() => {
         } else {
         } else {
             return 'Completed'
             return 'Completed'
         }
         }
-    } else {
-        return 'Still running...'
     }
     }
+
+    if (isWaitingInQueue(logEntry)) {
+        return 'Queued'
+    }
+
+    return 'Still running...'
 })
 })
 
 
 const exitCodeText = computed(() => {
 const exitCodeText = computed(() => {
@@ -51,6 +62,10 @@ const exitCodeText = computed(() => {
     return ''
     return ''
 })
 })
 
 
+const showQueueLink = computed(() => {
+    return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
+})
+
 const statusClass = computed(() => {
 const statusClass = computed(() => {
     const logEntry = props.logEntry
     const logEntry = props.logEntry
     if (!logEntry) return ''
     if (!logEntry) return ''
@@ -86,5 +101,14 @@ const statusClass = computed(() => {
   color: #ca79ff;
   color: #ca79ff;
 }
 }
 
 
+.queue-status-link {
+  color: #0d6efd;
+  text-decoration: none;
+}
+
+.queue-status-link:hover {
+  text-decoration: underline;
+}
+
 
 
 </style>
 </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>
 
 
       <div v-show="filteredLogs.length > 0">
       <div v-show="filteredLogs.length > 0">
-        <table class="logs-table">
+        <table class="logs-table row-hover">
           <thead>
           <thead>
             <tr>
             <tr>
               <th>Timestamp</th>
               <th>Timestamp</th>
@@ -82,7 +82,7 @@
                 </span>
                 </span>
               </td>
               </td>
               <td class="exit-code">
               <td class="exit-code">
-                <ActionStatusDisplay :logEntry="log" />
+                <ActionStatusDisplay :logEntry="log" :link-queued-status="true" />
               </td>
               </td>
             </tr>
             </tr>
           </tbody>
           </tbody>
@@ -107,6 +107,8 @@ import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import { requestReconnectNow } from '../../../js/websocket.js'
+import { needsArgumentForm } from '../utils/needsArgumentForm.js'
+import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 
 
 const route = useRoute()
 const route = useRoute()
 const router = useRouter()
 const router = useRouter()
@@ -266,13 +268,6 @@ function syncDurationTicker() {
   }, 1000)
   }, 1000)
 }
 }
 
 
-onUnmounted(() => {
-  if (durationTicker != null) {
-    clearInterval(durationTicker)
-    durationTicker = null
-  }
-})
-
 function handlePageChange(page) {
 function handlePageChange(page) {
   currentPage.value = page
   currentPage.value = page
   fetchActionLogs()
   fetchActionLogs()
@@ -290,11 +285,16 @@ async function startAction() {
     return
     return
   }
   }
 
 
+  if (needsArgumentForm(action.value)) {
+    router.push(`/actionBinding/${action.value.bindingId}/argumentForm`)
+    return
+  }
+
   try {
   try {
     requestReconnectNow()
     requestReconnectNow()
     const args = {
     const args = {
-      "bindingId": action.value.bindingId,
-      "arguments": []
+      bindingId: action.value.bindingId,
+      arguments: []
     }
     }
 
 
     const response = await window.client.startAction(args)
     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(() => {
 onMounted(() => {
   fetchAction()
   fetchAction()
   fetchActionLogs()
   fetchActionLogs()
+  window.addEventListener('EventExecutionStarted', onExecutionEvent)
+  window.addEventListener('EventExecutionFinished', onExecutionEvent)
 })
 })
 
 
 watch(
 watch(
@@ -319,6 +334,15 @@ watch(
   },
   },
   { immediate: false }
   { immediate: false }
 )
 )
+
+onUnmounted(() => {
+  window.removeEventListener('EventExecutionStarted', onExecutionEvent)
+  window.removeEventListener('EventExecutionFinished', onExecutionEvent)
+  if (durationTicker != null) {
+    clearInterval(durationTicker)
+    durationTicker = null
+  }
+})
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>

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

@@ -40,7 +40,13 @@
             <span class="argument-description" v-html="arg.description"></span>
             <span class="argument-description" v-html="arg.description"></span>
           </template>
           </template>
         </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>
           <p>No arguments required</p>
         </div>
         </div>
 
 
@@ -76,6 +82,8 @@ const formErrors = ref({})
 const actionArguments = ref([])
 const actionArguments = ref([])
 const popupOnStart = ref('')
 const popupOnStart = ref('')
 const formReady = ref(false)
 const formReady = ref(false)
+const justificationRequired = ref(false)
+const justificationValue = ref('')
 let isComponentMounted = true
 let isComponentMounted = true
 
 
 // Computed properties
 // Computed properties
@@ -103,6 +111,8 @@ async function setup() {
     icon.value = action.icon
     icon.value = action.icon
     popupOnStart.value = action.popupOnStart || ''
     popupOnStart.value = action.popupOnStart || ''
     actionArguments.value = action.arguments || []
     actionArguments.value = action.arguments || []
+    justificationRequired.value = action.justification || false
+    justificationValue.value = ''
     argValues.value = {}
     argValues.value = {}
     formErrors.value = {}
     formErrors.value = {}
     confirmationChecked.value = false
     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() {
 function getArgumentValues() {
   const ret = []
   const ret = []
 
 
   for (const arg of actionArguments.value) {
   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({
     ret.push({
       name: arg.name,
       name: arg.name,
-      value: value
+      value: formatArgumentValueForApi(arg, argValues.value[arg.name])
     })
     })
   }
   }
 
 
@@ -394,6 +426,10 @@ async function startAction(actionArgs) {
     uniqueTrackingId: getUniqueId()
     uniqueTrackingId: getUniqueId()
   }
   }
 
 
+  if (justificationRequired.value) {
+    startActionArgs.justification = justificationValue.value
+  }
+
   try {
   try {
     requestReconnectNow()
     requestReconnectNow()
     const response = await window.client.startAction(startActionArgs)
     const response = await window.client.startAction(startActionArgs)
@@ -418,6 +454,13 @@ async function handleSubmit(event) {
   }
   }
 
 
   // Set custom validity for required fields
   // 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) {
   for (const arg of actionArguments.value) {
     const value = argValues.value[arg.name]
     const value = argValues.value[arg.name]
     const inputElement = document.getElementById(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">
 		<div v-if="logEntry" class = "flex-row">
 				<dl class = "fg1">
 				<dl class = "fg1">
+					<dt>Action</dt>
+					<dd>
+						<LogActionTitle :action-title="title" :justification="logEntry.justification" />
+					</dd>
+
 					<dt>Duration</dt>
 					<dt>Duration</dt>
 					<dd><span v-html="duration"></span></dd>
 					<dd><span v-html="duration"></span></dd>
 
 
 					<dt>Status</dt>
 					<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>
 					</dd>
 				</dl>
 				</dl>
         <ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
         <ActionIconGlyph class="icon" role="img" :glyph="icon" style="align-self: start" />
@@ -64,6 +69,7 @@
 	import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 	import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
+import LogActionTitle from '../components/LogActionTitle.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
@@ -71,6 +77,7 @@ import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-fre
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 import { buttonResults } from '../stores/buttonResults'
 import { buttonResults } from '../stores/buttonResults'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import { requestReconnectNow } from '../../../js/websocket.js'
+import { needsArgumentForm } from '../utils/needsArgumentForm.js'
 
 
 const router = useRouter()
 const router = useRouter()
 
 
@@ -178,10 +185,16 @@ async function rerunAction() {
   }
   }
 
 
   try {
   try {
+    const binding = await window.client.getActionBinding({ bindingId })
+    if (needsArgumentForm(binding.action)) {
+      router.push(`/actionBinding/${bindingId}/argumentForm`)
+      return
+    }
+
     requestReconnectNow()
     requestReconnectNow()
     const startActionArgs = {
     const startActionArgs = {
-      "bindingId": bindingId,
-      "arguments": []
+      bindingId: bindingId,
+      arguments: []
     }
     }
 
 
     const res = await window.client.startAction(startActionArgs)
     const res = await window.client.startAction(startActionArgs)

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

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

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

@@ -11,86 +11,240 @@
 
 
     <p class="padding">{{ t('logs.queue-page-description') }}</p>
     <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>
       <p>{{ t('logs.queue-empty') }}</p>
       <router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
       <router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
     </div>
     </div>
   </Section>
   </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>
 </template>
 
 
 <script setup>
 <script setup>
 import { ref, onMounted, onUnmounted } from 'vue'
 import { ref, onMounted, onUnmounted } from 'vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
+import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
+import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
 
 
 const { t } = useI18n()
 const { t } = useI18n()
 
 
 const groups = ref([])
 const groups = ref([])
 const loading = ref(false)
 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) {
 function formatTimestamp (timestamp) {
@@ -107,8 +261,9 @@ function formatTimestamp (timestamp) {
 async function fetchQueue () {
 async function fetchQueue () {
   loading.value = true
   loading.value = true
   try {
   try {
+    const completedEntries = collectCompletedEntries(groups.value)
     const response = await window.client.getExecutionQueue({})
     const response = await window.client.getExecutionQueue({})
-    groups.value = response.groups || []
+    groups.value = mergeCompletedEntries(response.groups || [], completedEntries)
   } catch (err) {
   } catch (err) {
     console.error('Failed to fetch execution queue:', err)
     console.error('Failed to fetch execution queue:', err)
     window.showBigError('fetch-queue', 'getting execution queue', err, false)
     window.showBigError('fetch-queue', 'getting execution queue', err, false)
@@ -119,45 +274,37 @@ async function fetchQueue () {
 
 
 onMounted(() => {
 onMounted(() => {
   fetchQueue()
   fetchQueue()
-  window.addEventListener('EventExecutionStarted', fetchQueue)
-  window.addEventListener('EventExecutionFinished', fetchQueue)
+  window.addEventListener('EventExecutionStarted', onExecutionStarted)
+  window.addEventListener('EventExecutionFinished', onExecutionFinished)
 })
 })
 
 
 onUnmounted(() => {
 onUnmounted(() => {
-  window.removeEventListener('EventExecutionStarted', fetchQueue)
-  window.removeEventListener('EventExecutionFinished', fetchQueue)
+  window.removeEventListener('EventExecutionStarted', onExecutionStarted)
+  window.removeEventListener('EventExecutionFinished', onExecutionFinished)
 })
 })
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.queue-groups {
+.queue-group-heading {
   display: flex;
   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;
   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 {
 .queue-entity {
@@ -166,12 +313,9 @@ onUnmounted(() => {
   color: #666;
   color: #666;
 }
 }
 
 
-.queue-group-limit {
-  white-space: nowrap;
-}
-
-.icon {
+.queue-group-icon {
   font-size: 1.5em;
   font-size: 1.5em;
+  flex-shrink: 0;
 }
 }
 
 
 .timestamp {
 .timestamp {
@@ -185,16 +329,14 @@ onUnmounted(() => {
   font-size: smaller;
   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 {
 .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
 export const DEFAULT_UI_WAIT_MS = 3000
 
 
+const executionDialogStatusBy = By.css('.execution-dialog-status')
+
 export async function getActionButtons () {
 export async function getActionButtons () {
   // Currently, only the active dashboard's contents are rendered,
   // Currently, only the active dashboard's contents are rendered,
   // so we don't need to scope the selector by dashboard title.
   // 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) {
 export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
   await webdriver.wait(new Condition('wait for execution status', async () => {
   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
     return statusElements.length > 0
   }), timeoutMs)
   }), timeoutMs)
 
 
   await webdriver.wait(new Condition('wait for execution to finish', async () => {
   await webdriver.wait(new Condition('wait for execution to finish', async () => {
     try {
     try {
-      const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
+      const statusElement = await webdriver.findElement(executionDialogStatusBy)
       const statusText = await statusElement.getText()
       const statusText = await statusElement.getText()
       return !statusText.includes('Still running')
       return !statusText.includes('Still running')
     } catch (e) {
     } catch (e) {
@@ -163,7 +165,7 @@ export async function getNavigationLinks() {
 
 
 export async function requireExecutionDialogStatus (webdriver, expected) {
 export async function requireExecutionDialogStatus (webdriver, expected) {
   await webdriver.wait(new Condition('wait for action to be running', async function () {
   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()
     const actual = await dialogStatus.getText()
 
 
     if (actual === expected) {
     if (actual === expected) {

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

@@ -40,7 +40,7 @@ describe('config: entityFilesWithLongIntsUseStandardForm', function () {
     await waitForLogsPage()
     await waitForLogsPage()
     await waitForExecutionComplete()
     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()
     const statusText = await statusElement.getText()
 
 
     // The status should indicate success (not "Executing..." or "Failed")
     // The status should indicate success (not "Executing..." or "Failed")

+ 1 - 1
lang/combined_output.json

@@ -350,4 +350,4 @@
             "welcome": "欢迎使用 OliveTin"
             "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;
 	repeated string exec_on_file_changed_in_dir = 13;
 	string exec_on_calendar_file = 14;
 	string exec_on_calendar_file = 14;
 	repeated ActionWebhookExecHint exec_on_webhooks = 15;
 	repeated ActionWebhookExecHint exec_on_webhooks = 15;
+	bool justification = 16;
+	bool has_running_instance = 17;
+	bool has_queued_instance = 18;
 }
 }
 
 
 message ActionWebhookExecHint {
 message ActionWebhookExecHint {
@@ -95,6 +98,7 @@ message StartActionRequest {
 	repeated StartActionArgument arguments = 2;
 	repeated StartActionArgument arguments = 2;
 
 
 	string unique_tracking_id = 3;
 	string unique_tracking_id = 3;
+	string justification = 4;
 }
 }
 
 
 message StartActionArgument {
 message StartActionArgument {
@@ -110,6 +114,8 @@ message StartActionAndWaitRequest {
 	string action_id = 1;
 	string action_id = 1;
 
 
 	repeated StartActionArgument arguments = 2;
 	repeated StartActionArgument arguments = 2;
+
+	string justification = 3;
 }
 }
 
 
 message StartActionAndWaitResponse {
 message StartActionAndWaitResponse {
@@ -160,6 +166,7 @@ message LogEntry {
 	string binding_id = 20; // Binding ID for matching rate limits to action buttons
 	string binding_id = 20; // Binding ID for matching rate limits to action buttons
 	bool queued = 21;
 	bool queued = 21;
 	string queued_for_group = 22;
 	string queued_for_group = 22;
+	string justification = 23;
 }
 }
 
 
 message GetLogsResponse {
 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"`
 	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"`
 	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"`
 	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
 	unknownFields            protoimpl.UnknownFields
 	sizeCache                protoimpl.SizeCache
 	sizeCache                protoimpl.SizeCache
 }
 }
@@ -177,6 +180,27 @@ func (x *Action) GetExecOnWebhooks() []*ActionWebhookExecHint {
 	return nil
 	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 {
 type ActionWebhookExecHint struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Template      string                 `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
 	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"`
 	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"`
 	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"`
 	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
 	unknownFields    protoimpl.UnknownFields
 	sizeCache        protoimpl.SizeCache
 	sizeCache        protoimpl.SizeCache
 }
 }
@@ -857,6 +882,13 @@ func (x *StartActionRequest) GetUniqueTrackingId() string {
 	return ""
 	return ""
 }
 }
 
 
+func (x *StartActionRequest) GetJustification() string {
+	if x != nil {
+		return x.Justification
+	}
+	return ""
+}
+
 type StartActionArgument struct {
 type StartActionArgument struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Name          string                 `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
 	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"`
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	ActionId      string                 `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"`
 	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"`
 	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
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 	sizeCache     protoimpl.SizeCache
 }
 }
@@ -1005,6 +1038,13 @@ func (x *StartActionAndWaitRequest) GetArguments() []*StartActionArgument {
 	return nil
 	return nil
 }
 }
 
 
+func (x *StartActionAndWaitRequest) GetJustification() string {
+	if x != nil {
+		return x.Justification
+	}
+	return ""
+}
+
 type StartActionAndWaitResponse struct {
 type StartActionAndWaitResponse struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	LogEntry      *LogEntry              `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"`
 	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
 	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"`
 	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"`
 	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
 	unknownFields            protoimpl.UnknownFields
 	sizeCache                protoimpl.SizeCache
 	sizeCache                protoimpl.SizeCache
 }
 }
@@ -1489,6 +1530,13 @@ func (x *LogEntry) GetQueuedForGroup() string {
 	return ""
 	return ""
 }
 }
 
 
+func (x *LogEntry) GetJustification() string {
+	if x != nil {
+		return x.Justification
+	}
+	return ""
+}
+
 type GetLogsResponse struct {
 type GetLogsResponse struct {
 	state          protoimpl.MessageState `protogen:"open.v1"`
 	state          protoimpl.MessageState `protogen:"open.v1"`
 	Logs           []*LogEntry            `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
 	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 = "" +
 const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\n" +
 	"\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" +
 	"\x06Action\x12\x1d\n" +
 	"\n" +
 	"\n" +
 	"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\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_created_in_dir\x18\f \x03(\tR\x16execOnFileCreatedInDir\x12;\n" +
 	"\x1bexec_on_file_changed_in_dir\x18\r \x03(\tR\x16execOnFileChangedInDir\x121\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" +
 	"\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" +
 	"\x15ActionWebhookExecHint\x12\x1a\n" +
 	"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" +
 	"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" +
 	"\n" +
 	"\n" +
@@ -4359,20 +4410,22 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\ventity_type\x18\a \x01(\tR\n" +
 	"\ventity_type\x18\a \x01(\tR\n" +
 	"entityType\x12\x1d\n" +
 	"entityType\x12\x1d\n" +
 	"\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" +
 	"\x12StartActionRequest\x12\x1d\n" +
 	"\n" +
 	"\n" +
 	"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +
 	"binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" +
 	"\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12,\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" +
 	"\x13StartActionArgument\x12\x12\n" +
 	"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
 	"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
 	"\x05value\x18\x02 \x01(\tR\x05value\"I\n" +
 	"\x05value\x18\x02 \x01(\tR\x05value\"I\n" +
 	"\x13StartActionResponse\x122\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" +
 	"\x19StartActionAndWaitRequest\x12\x1b\n" +
 	"\taction_id\x18\x01 \x01(\tR\bactionId\x12B\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" +
 	"\x1aStartActionAndWaitResponse\x126\n" +
 	"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" +
 	"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" +
 	"\x17StartActionByGetRequest\x12\x1b\n" +
 	"\x17StartActionByGetRequest\x12\x1b\n" +
@@ -4388,7 +4441,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\vdate_filter\x18\x02 \x01(\tR\n" +
 	"\vdate_filter\x18\x02 \x01(\tR\n" +
 	"dateFilter\x12\x1b\n" +
 	"dateFilter\x12\x1b\n" +
 	"\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\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" +
 	"\bLogEntry\x12)\n" +
 	"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
 	"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
 	"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
 	"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
@@ -4413,7 +4466,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\n" +
 	"\n" +
 	"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
 	"binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" +
 	"\x06queued\x18\x15 \x01(\bR\x06queued\x12(\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" +
 	"\x0fGetLogsResponse\x12-\n" +
 	"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\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" +
 	"\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) {
 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)
 	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{
 	execReq := executor.ExecutionRequest{
 		Binding:           pair,
 		Binding:           pair,
 		TrackingID:        req.Msg.UniqueTrackingId,
 		TrackingID:        req.Msg.UniqueTrackingId,
-		Arguments:         args,
+		Arguments:         startActionArgumentsFromProto(req.Msg.Arguments),
+		Justification:     req.Msg.Justification,
 		AuthenticatedUser: authenticatedUser,
 		AuthenticatedUser: authenticatedUser,
 		Cfg:               api.cfg,
 		Cfg:               api.cfg,
 	}
 	}
 
 
 	api.executor.ExecRequest(&execReq)
 	api.executor.ExecRequest(&execReq)
 
 
-	ret := &apiv1.StartActionResponse{
+	return connect.NewResponse(&apiv1.StartActionResponse{
 		ExecutionTrackingId: execReq.TrackingID,
 		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) {
 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
 	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{
 	execReq := executor.ExecutionRequest{
 		Binding:           binding,
 		Binding:           binding,
 		TrackingID:        uuid.NewString(),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		Arguments:         args,
+		Justification:     justification,
 		AuthenticatedUser: user,
 		AuthenticatedUser: user,
 		Cfg:               api.cfg,
 		Cfg:               api.cfg,
 	}
 	}
@@ -280,19 +276,22 @@ func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.Action
 	return binding, nil
 	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) {
 func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
 	binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
 	binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		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)
 	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 {
 	if !ok {
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
 		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,
 		User:                     logEntry.Username,
 		BindingId:                logEntry.GetBindingId(),
 		BindingId:                logEntry.GetBindingId(),
 		DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
 		DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
+		Justification:            logEntry.Justification,
 	}
 	}
 
 
 	if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
 	if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
@@ -531,11 +531,7 @@ func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedU
 	}
 	}
 
 
 	return &apiv1.GetActionBindingResponse{
 	return &apiv1.GetActionBindingResponse{
-		Action: buildAction(binding, &DashboardRenderRequest{
-			cfg:               api.cfg,
-			AuthenticatedUser: user,
-			ex:                api.executor,
-		}),
+		Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
 	}, nil
 	}, nil
 }
 }
 
 
@@ -576,13 +572,15 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser)
 }
 }
 
 
 func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
 func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
-	return &DashboardRenderRequest{
+	rr := &DashboardRenderRequest{
 		AuthenticatedUser: user,
 		AuthenticatedUser: user,
 		cfg:               api.cfg,
 		cfg:               api.cfg,
 		ex:                api.executor,
 		ex:                api.executor,
 		EntityType:        entityType,
 		EntityType:        entityType,
 		EntityKey:         entityKey,
 		EntityKey:         entityKey,
 	}
 	}
+	populateActiveBindingStates(rr)
+	return rr
 }
 }
 
 
 func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
 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) {
 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)
 	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{
 	execReq := executor.ExecutionRequest{
 		Binding:           execReqLogEntry.Binding,
 		Binding:           execReqLogEntry.Binding,
 		Arguments:         make(map[string]string),
 		Arguments:         make(map[string]string),
@@ -1548,8 +1532,26 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
 
 
 	api.executor.ExecRequest(&execReq)
 	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 {
 func newServer(ex *executor.Executor) *oliveTinAPI {

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

@@ -16,12 +16,56 @@ import (
 	"github.com/OliveTin/OliveTin/internal/tpl"
 	"github.com/OliveTin/OliveTin/internal/tpl"
 )
 )
 
 
+type bindingActiveState struct {
+	hasRunning bool
+	hasQueued  bool
+}
+
 type DashboardRenderRequest struct {
 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 {
 func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
@@ -135,43 +179,56 @@ func actionFromBinding(actionBinding *executor.ActionBinding) (*executor.ActionB
 	return actionBinding, actionBinding.Action
 	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 {
 func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
 	binding, action := actionFromBinding(actionBinding)
 	binding, action := actionFromBinding(actionBinding)
 	if binding == nil {
 	if binding == nil {
 		return 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{
 	btn := apiv1.Action{
 		BindingId:                binding.ID,
 		BindingId:                binding.ID,
 		Title:                    tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
 		Title:                    tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
 		Icon:                     tpl.ParseTemplateOfActionBeforeExec(action.Icon, 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),
 		Order:                    int32(binding.ConfigOrder),
 		Timeout:                  int32(action.Timeout),
 		Timeout:                  int32(action.Timeout),
-		DatetimeRateLimitExpires: datetimeRateLimitExpires,
+		DatetimeRateLimitExpires: formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding)),
+		Justification:            action.Justification,
 	}
 	}
 
 
+	applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates)
 	applyActionExecTriggers(&btn, action)
 	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
 	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"`
 	MaxConcurrent          int              `koanf:"maxConcurrent"`
 	MaxRate                []RateSpec       `koanf:"maxRate"`
 	MaxRate                []RateSpec       `koanf:"maxRate"`
 	Arguments              []ActionArgument `koanf:"arguments"`
 	Arguments              []ActionArgument `koanf:"arguments"`
+	OnClick                string           `koanf:"onclick"`
 	PopupOnStart           string           `koanf:"popupOnStart"`
 	PopupOnStart           string           `koanf:"popupOnStart"`
 	SaveLogs               SaveLogsConfig   `koanf:"saveLogs"`
 	SaveLogs               SaveLogsConfig   `koanf:"saveLogs"`
 	EnabledExpression      string           `koanf:"enabledExpression"`
 	EnabledExpression      string           `koanf:"enabledExpression"`
 	Groups                 []string         `koanf:"groups"`
 	Groups                 []string         `koanf:"groups"`
+	Justification          bool             `koanf:"justification"`
 }
 }
 
 
 // ActionGroup defines shared limits and metadata for a set of actions.
 // 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.
 // WebhookConfig defines configuration for generic webhook triggers.
 type WebhookConfig struct {
 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.
 // Entity represents a "thing" that can have multiple actions associated with it.
@@ -175,6 +178,7 @@ type Config struct {
 	WebUIDir                        string                     `koanf:"webUIDir"`
 	WebUIDir                        string                     `koanf:"webUIDir"`
 	CronSupportForSeconds           bool                       `koanf:"cronSupportForSeconds"`
 	CronSupportForSeconds           bool                       `koanf:"cronSupportForSeconds"`
 	SectionNavigationStyle          string                     `koanf:"sectionNavigationStyle"`
 	SectionNavigationStyle          string                     `koanf:"sectionNavigationStyle"`
+	DefaultOnClick                  string                     `koanf:"defaultOnClick"`
 	DefaultPopupOnStart             string                     `koanf:"defaultPopupOnStart"`
 	DefaultPopupOnStart             string                     `koanf:"defaultPopupOnStart"`
 	InsecureAllowDumpOAuth2UserData bool                       `koanf:"insecureAllowDumpOAuth2UserData"`
 	InsecureAllowDumpOAuth2UserData bool                       `koanf:"insecureAllowDumpOAuth2UserData"`
 	InsecureAllowDumpVars           bool                       `koanf:"insecureAllowDumpVars"`
 	InsecureAllowDumpVars           bool                       `koanf:"insecureAllowDumpVars"`
@@ -290,6 +294,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
 	config.WebUIDir = "./webui"
 	config.WebUIDir = "./webui"
 	config.CronSupportForSeconds = false
 	config.CronSupportForSeconds = false
 	config.SectionNavigationStyle = "sidebar"
 	config.SectionNavigationStyle = "sidebar"
+	config.DefaultOnClick = "nothing"
 	config.DefaultPopupOnStart = "nothing"
 	config.DefaultPopupOnStart = "nothing"
 	config.InsecureAllowDumpVars = false
 	config.InsecureAllowDumpVars = false
 	config.InsecureAllowDumpSos = false
 	config.InsecureAllowDumpSos = false

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

@@ -18,6 +18,7 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeLogHistoryPageSize()
 	cfg.sanitizeLogHistoryPageSize()
 	cfg.sanitizeLocalUsers()
 	cfg.sanitizeLocalUsers()
 	cfg.sanitizeSecurityHeaders()
 	cfg.sanitizeSecurityHeaders()
+	cfg.sanitizeOnClickDefaults()
 
 
 	// log.Infof("cfg %p", cfg)
 	// log.Infof("cfg %p", cfg)
 
 
@@ -177,7 +178,9 @@ func (action *Action) sanitize(cfg *Config) {
 
 
 	action.ID = getActionID(action)
 	action.ID = getActionID(action)
 	action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
 	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 {
 	if action.MaxConcurrent < 1 {
 		action.MaxConcurrent = 1
 		action.MaxConcurrent = 1
@@ -359,7 +362,7 @@ func getActionID(action *Action) string {
 }
 }
 
 
 //gocyclo:ignore
 //gocyclo:ignore
-func sanitizePopupOnStart(raw string, cfg *Config) string {
+func sanitizeOnClick(raw string, cfg *Config) string {
 	switch raw {
 	switch raw {
 	case "execution-dialog":
 	case "execution-dialog":
 		return raw
 		return raw
@@ -372,10 +375,43 @@ func sanitizePopupOnStart(raw string, cfg *Config) string {
 	case "history":
 	case "history":
 		return raw
 		return raw
 	default:
 	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() {
 func (arg *ActionArgument) sanitize() {
 	if arg.Title == "" {
 	if arg.Title == "" {
 		arg.Title = arg.Name
 		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")
 	a := c.findAction("With history")
 	if assert.NotNil(t, a) {
 	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) {
 func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 	c := DefaultConfig()
 	c := DefaultConfig()
 
 

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

@@ -87,6 +87,7 @@ type ExecutionRequest struct {
 	Cfg               *config.Config
 	Cfg               *config.Config
 	AuthenticatedUser *authpublic.AuthenticatedUser
 	AuthenticatedUser *authpublic.AuthenticatedUser
 	TriggerDepth      int
 	TriggerDepth      int
+	Justification     string
 
 
 	logEntry                *InternalLogEntry
 	logEntry                *InternalLogEntry
 	finalParsedCommand      string
 	finalParsedCommand      string
@@ -166,8 +167,9 @@ type InternalLogEntry struct {
 		that logs are lightweight (so we don't need to have an action associated to
 		that logs are lightweight (so we don't need to have an action associated to
 		logs, etc. Therefore, we duplicate those values here.
 		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.
 // .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)
 	trackingIds := make([]*InternalLogEntry, 0, pageCount)
 
 
 	if totalLogCount > 0 {
 	if totalLogCount > 0 {
-		for i := endIndex; i <= startIndex; i++ {
+		for i := startIndex; i >= endIndex; i-- {
 			trackingIds = append(trackingIds, e.logs[e.logsTrackingIdsByDate[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)
 	endIndex := max(0, (startIndex-pageCount)+1)
 
 
 	out := make([]*InternalLogEntry, 0, pageCount)
 	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])
 		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.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity)
 		entry.ActionIcon = req.Binding.Action.Icon
 		entry.ActionIcon = req.Binding.Action.Icon
 		entry.Tags = req.Tags
 		entry.Tags = req.Tags
+		entry.Justification = ResolveJustification(req)
 		if req.Binding.Entity != nil {
 		if req.Binding.Entity != nil {
 			entry.EntityPrefix = req.Binding.Entity.UniqueKey
 			entry.EntityPrefix = req.Binding.Entity.UniqueKey
 		}
 		}
@@ -1148,9 +1151,18 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque
 	cmd.Stdout = streamer
 	cmd.Stdout = streamer
 	cmd.Stderr = streamer
 	cmd.Stderr = streamer
 	cmd.Env = buildEnv(req.Arguments)
 	cmd.Env = buildEnv(req.Arguments)
+
+	started := false
 	req.mutateLogEntry(func(entry *InternalLogEntry) {
 	req.mutateLogEntry(func(entry *InternalLogEntry) {
+		if entry.ExecutionStarted {
+			return
+		}
 		entry.ExecutionStarted = true
 		entry.ExecutionStarted = true
+		started = true
 	})
 	})
+	if started {
+		notifyListenersStarted(req)
+	}
 }
 }
 
 
 func stepExecAfter(req *ExecutionRequest) bool {
 func stepExecAfter(req *ExecutionRequest) bool {
@@ -1284,6 +1296,7 @@ func triggerLoop(req *ExecutionRequest) {
 			Arguments:         req.Arguments,
 			Arguments:         req.Arguments,
 			Cfg:               req.Cfg,
 			Cfg:               req.Cfg,
 			TriggerDepth:      req.TriggerDepth + 1,
 			TriggerDepth:      req.TriggerDepth + 1,
+			Justification:     fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle),
 		}
 		}
 
 
 		req.executor.ExecRequest(trigger)
 		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")
 	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) {
 func TestDifferentGroupsRunConcurrently(t *testing.T) {
 	t.Parallel()
 	t.Parallel()
 
 
@@ -213,6 +286,30 @@ func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) {
 	}, 2*time.Second, 10*time.Millisecond)
 	}, 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) {
 func assertWaitGroupPending(t *testing.T, wg *sync.WaitGroup) {
 	t.Helper()
 	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
 		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
 	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)
 	binding := h.executor.FindBindingWithNoEntity(action)
 	if binding == nil {
 	if binding == nil {
 		log.WithFields(log.Fields{
 		log.WithFields(log.Fields{
@@ -156,6 +165,7 @@ func (h *WebhookHandler) executeAction(action *config.Action, args map[string]st
 		Cfg:               h.cfg,
 		Cfg:               h.cfg,
 		Tags:              []string{"webhook"},
 		Tags:              []string{"webhook"},
 		Arguments:         definedArgs,
 		Arguments:         definedArgs,
+		Justification:     justification,
 		AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
 		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
 	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) {
 func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) {
 	matcher, err := NewJSONMatcher(m.bodyBytes)
 	matcher, err := NewJSONMatcher(m.bodyBytes)
 	if err != nil {
 	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)
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов